Runtime API for OpenFeed grants stored on an Ash resource.
See AshOpenFeed.Grant for the extension that makes a resource usable here.
The shape of it
# Starting a consent flow
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.authorize_url(MyApp.OpenFeed.Grant, flow)
# At the callback, after checking state matches
{:ok, grant} =
AshOpenFeed.complete_authorization(MyApp.OpenFeed.Grant, code, flow,
attributes: %{user_id: current_user.id}
)
# Reading data. Refreshes the token first if it is close to expiry.
{:ok, token, grant} = AshOpenFeed.access_token(grant)
{:ok, accounts} = OpenFeed.Sharing.banking_accounts(config, token)
# Or in one step
{:ok, accounts} =
AshOpenFeed.with_token(grant, &OpenFeed.Sharing.banking_accounts(&1, &2))Concurrent refresh is safe
access_token/2 refreshes when a token is near expiry, and does so without
taking a lock. That is deliberate, not an oversight.
OpenFeed does not rotate refresh tokens — rotateRefreshToken is off, because
FAPI 2.0 permits sender-constraint (DPoP) as the alternative to rotation. So a
refresh token stays valid after use, two concurrent refreshes both succeed, and
the last write wins with both tokens usable. Concurrent refresh is therefore
wasteful, not destructive.
If you want to avoid the waste when fanning out across many grants, that is a
job scheduling concern: give your worker a uniqueness constraint (AshOban's
unique option, say) rather than reaching for a database lock.
Summary
Functions
Get a usable access token for a grant, refreshing if it is near expiry.
Build a URL that sends the consumer back to OpenFeed to amend an existing consent — adding or removing the accounts they share.
Build the consent redirect URL for a grant resource.
Exchange an authorization code and store the resulting grant.
The resolved OpenFeed.Config for a resource or grant.
See OpenFeed.Auth.new_flow/0. Keep all three values in the session.
Reconcile local grants against OpenFeed's app-level grant index.
Refresh a grant's tokens unconditionally.
Re-read a grant's status from OpenFeed and reconcile the local record.
Revoke a grant at OpenFeed, then mark it revoked locally.
Store tokens on a grant resource, upserting on grant_id.
Run a function with a fresh access token and the resolved config.
Functions
@spec access_token( Ash.Resource.Record.t(), keyword() ) :: {:ok, String.t(), Ash.Resource.Record.t()} | {:error, term()}
Get a usable access token for a grant, refreshing if it is near expiry.
Returns the token and the grant, which may have been updated. Always use the returned grant afterwards — the stale one has the old token on it.
Options
:slack— refresh this many seconds before actual expiry. Defaults to 120, which guards against a token that passes the check and then expires mid-flight.:actor,:tenant,:authorize?— passed through toAsh.update/2.
@spec amend_url(Ash.Resource.Record.t(), map(), keyword()) :: {:ok, String.t()} | {:error, term()}
Build a URL that sends the consumer back to OpenFeed to amend an existing consent — adding or removing the accounts they share.
This is grant_management_action: "replace" with the grant's id. OpenFeed keeps
the same grant_id and increments its revision, so when the consumer
returns, complete_authorization/4 upserts onto the same row. There is no
second grant and nothing to merge.
A consumer has at most one active grant per app, so this is the only way to
change what an existing consent covers — a fresh authorize_url/3 would just
reuse the grant they already have.
Example
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.amend_url(grant, flow)
redirect(conn, external: url)Handle the callback exactly as you handle a first-time connection.
@spec authorize_url(Ash.Resource.t(), map(), keyword()) :: {:ok, String.t()} | {:error, term()}
Build the consent redirect URL for a grant resource.
flow is the map from new_flow/0. Extra options are passed to
OpenFeed.Auth.authorize_url/2, so grant_management_action: "replace" and
grant_id: work here for amending an existing consent.
@spec complete_authorization(Ash.Resource.t(), String.t(), map(), keyword()) :: {:ok, Ash.Resource.Record.t()} | {:error, term()}
Exchange an authorization code and store the resulting grant.
Compare state yourself before calling this — this function cannot do it,
because the expected value lives in your session.
Options
:attributes— extra attributes to set on the grant, typically the association to your own user. Must be accepted by the create action, so add them to a customupsert_from_tokensif the default rejects them.:actor,:tenant,:authorize?— passed through toAsh.create/2.
@spec config(Ash.Resource.t() | Ash.Resource.Record.t()) :: {:ok, OpenFeed.Config.t()} | {:error, term()}
The resolved OpenFeed.Config for a resource or grant.
See OpenFeed.Auth.new_flow/0. Keep all three values in the session.
@spec reconcile_grants( Ash.Resource.t(), keyword() ) :: {:ok, map()} | {:error, term()}
Reconcile local grants against OpenFeed's app-level grant index.
OpenFeed has no webhooks for grant lifecycle events, so polling is the only way to notice a revocation or an amendment without first failing a data call.
This is inherently two-phase, because the index is deliberately lightweight —
it carries only id, revision and lastUpdated:
- List every grant the app holds (
GET /v1/app/grants). - For each local grant, compare. A grant missing from the index has been revoked upstream. A higher revision in the index means the consumer amended their consent, so the full state is fetched and applied.
Returns a summary:
{:ok, %{checked: 12, revoked: 1, updated: 2, unchanged: 9, unknown: 0}}unknown counts index entries with no local grant. Those are not created — a
grant you have no record of is not something this function can invent tokens
for.
Scopes, and degrading without them
Phase 1 needs only the app-level openfeed-au:grant:all:list scope, which
comes from a client-credentials token and is not tied to any consumer. So
detecting revocations and amendments works even if you never requested the
per-grant management scopes.
Phase 2 needs a grant-bound token with openfeed-au:grant:self:query, i.e.
grant_management? true. Without it, an amended grant is still noticed —
its revision is recorded and it is counted as updated, so a re-sync will pick
up the new account set — but the authorised id lists cannot be read.
Cost
Free. App-level calls are not metered, and OpenFeed charges per grant per calendar month rather than per request.
Options
:grants— reconcile only these local grants. Defaults to every grant on the resource whose status is:active.:actor,:tenant,:authorize?— passed through to Ash.
@spec refresh( Ash.Resource.Record.t(), keyword() ) :: {:ok, String.t(), Ash.Resource.Record.t()} | {:error, term()}
Refresh a grant's tokens unconditionally.
access_token/2 calls this when needed; call it directly only if you want to
force a refresh.
@spec refresh_status( Ash.Resource.Record.t(), keyword() ) :: {:ok, Ash.Resource.Record.t()} | {:error, term()}
Re-read a grant's status from OpenFeed and reconcile the local record.
Use this to notice a revocation without waiting for a data call to fail.
Needs grant_management? true.
@spec revoke( Ash.Resource.Record.t(), keyword() ) :: {:ok, Ash.Resource.Record.t()} | {:error, term()}
Revoke a grant at OpenFeed, then mark it revoked locally.
Needs grant_management? true on the resource and the
openfeed-au:grant:self:revoke scope to have been granted.
A grant OpenFeed does not recognise reports :not_found. That is still marked
revoked locally, because whatever the cause, it is not usable.
@spec store_tokens(Ash.Resource.t(), OpenFeed.Tokens.t(), keyword()) :: {:ok, Ash.Resource.Record.t()} | {:error, term()}
Store tokens on a grant resource, upserting on grant_id.
Usually reached through complete_authorization/4; useful directly when you
already hold an OpenFeed.Tokens.
@spec with_token( Ash.Resource.Record.t(), (OpenFeed.Config.t(), String.t() -> result), keyword() ) :: result | {:error, term()} when result: term()
Run a function with a fresh access token and the resolved config.
Saves the access_token/2 dance for the common case:
AshOpenFeed.with_token(grant, &OpenFeed.Sharing.banking_accounts(&1, &2))The function receives (config, access_token). On {:error, %OpenFeed.Error{}}
the grant's local state is reconciled — a :grant_revoked marks it revoked, a
:credit_exhausted marks metering suspended — so the next caller does not have
to make the same failing request to find out.