AshOpenFeed (AshOpenFeed v0.1.0)

Copy Markdown View Source

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

access_token(grant, opts \\ [])

@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 to Ash.update/2.

amend_url(grant, flow, opts \\ [])

@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.

authorize_url(resource, flow, opts \\ [])

@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.

complete_authorization(resource, code, flow, opts \\ [])

@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 custom upsert_from_tokens if the default rejects them.
  • :actor, :tenant, :authorize? — passed through to Ash.create/2.

config(resource_or_grant)

@spec config(Ash.Resource.t() | Ash.Resource.Record.t()) ::
  {:ok, OpenFeed.Config.t()} | {:error, term()}

The resolved OpenFeed.Config for a resource or grant.

new_flow()

@spec new_flow() :: %{state: String.t(), nonce: String.t(), pkce_verifier: String.t()}

See OpenFeed.Auth.new_flow/0. Keep all three values in the session.

reconcile_grants(resource, opts \\ [])

@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:

  1. List every grant the app holds (GET /v1/app/grants).
  2. 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.

refresh(grant, opts \\ [])

@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.

refresh_status(grant, opts \\ [])

@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.

revoke(grant, opts \\ [])

@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.

store_tokens(resource, tokens, opts \\ [])

@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.

with_token(grant, fun, opts \\ [])

@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.