OpenFeed.Auth (OpenFeed v0.1.0)

Copy Markdown View Source

OpenFeed's FAPI 2.0 authorization flow, built on oidcc.

The flow

  1. authorize_url/2 — pushes the authorization request (PAR) and returns the front-channel URL. Keep the state, nonce and pkce_verifier it generated; you need all three at the callback.
  2. The consumer approves at OpenFeed and is redirected back with a code.
  3. exchange_code/3 — swaps the code for tokens.
  4. refresh/3 — renews the access token when it expires.

PAR happens transparently inside authorize_url/2: OpenFeed's metadata sets require_pushed_authorization_requests, and oidcc honours it.

Why some calls skip the FAPI2 profile

oidcc's :fapi2_security_profile is the right thing for the authorization code flow, and this library uses it there. It cannot be used for the other token-endpoint calls, and the reason is worth stating because it looks like an oversight:

enforce_s256_pkce/2 sets Opts#{require_pkce => true} unconditionally, overwriting any caller value. Grants with no authorization code — refresh and client credentials — plus introspection therefore fail the PKCE check, and there is no supported way to keep the profile and opt out.

So those calls drop :profiles and this module restores what that loses, explicitly: preferred_auth_methods and trusted_audiences. The profile's limit_signing_alg_values is unnecessary here because OpenFeed only ever issues PS256, and its TLS hardening is inert behind a custom HTTP adapter (see OpenFeed.ReqHttpAdapter). The authorization-relevant parts — limit_response_types, enforce_par, enforce_iss_parameter — do not apply to these endpoints at all.

See OpenFeed.ProviderConfiguration for the introspection metadata override that OpenFeed's discovery document requires.

Summary

Functions

Build the consent redirect URL, pushing the authorization request first.

Get a client-credentials token, for app-level endpoints.

Exchange an authorization code for tokens.

Introspect an access token.

Generate the state, nonce and PKCE verifier for a new authorization.

Functions

authorize_url(config, opts)

@spec authorize_url(
  OpenFeed.Config.t(),
  keyword()
) :: {:ok, String.t()} | {:error, OpenFeed.Error.t()}

Build the consent redirect URL, pushing the authorization request first.

Options

  • :state, :nonce, :pkce_verifier (all required) — from new_flow/0.

  • :grant_management_action"create" or "replace". Omitted by default, because OpenFeed treats it as optional and infers the action: create when the request carries no grant_id, replace when it does. Pass "replace" with :grant_id to amend an existing consent.

    Note that "query" and "revoke" appear in OpenFeed's grant_management_actions_supported metadata but are not accepted here — they are the /v1/grants/{id} endpoints. See OpenFeed.Sharing.grant/4 and OpenFeed.Sharing.revoke_grant/4.

  • :grant_id — the grant to amend, for "replace".

  • :scopes — override the config's scopes for this authorization.

client_credentials_token(config, scopes \\ [:grant_list])

@spec client_credentials_token(OpenFeed.Config.t(), [atom() | String.t()]) ::
  {:ok, OpenFeed.Tokens.t()} | {:error, OpenFeed.Error.t()}

Get a client-credentials token, for app-level endpoints.

These are not grant-bound. Use them for /v1/app and /v1/app/grants. Defaults to the scopes needed to list your app's grants.

exchange_code(config, code, opts)

@spec exchange_code(OpenFeed.Config.t(), String.t(), keyword()) ::
  {:ok, OpenFeed.Tokens.t()} | {:error, OpenFeed.Error.t()}

Exchange an authorization code for tokens.

Pass the nonce and pkce_verifier from the new_flow/0 call that produced the redirect. Compare state yourself before calling this.

introspect(config, access_token)

@spec introspect(OpenFeed.Config.t(), String.t()) ::
  {:ok, map()} | {:error, OpenFeed.Error.t()}

Introspect an access token.

OpenFeed's introspection response is deliberately minimal: active plus grant_id, and nothing else.

This is how grant_id is recovered for sharing-api tokens, which are opaque by design. exchange_code/3 and refresh/4 call it automatically when the claim is absent; pass introspect_grant_id: false to suppress that.

new_flow()

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

Generate the state, nonce and PKCE verifier for a new authorization.

Store all three against the browser session; exchange_code/3 needs the nonce and verifier, and you must compare state yourself on the way back.

refresh(config, refresh_token, expected_subject, opts \\ [])

@spec refresh(OpenFeed.Config.t(), String.t(), String.t(), keyword()) ::
  {:ok, OpenFeed.Tokens.t()} | {:error, OpenFeed.Error.t()}

Refresh an access token.

expected_subject must be the sub stored on the grant — oidcc verifies the new token belongs to the same consumer.

OpenFeed does not rotate refresh tokens: rotateRefreshToken is off, because FAPI2 permits sender-constraint (DPoP) as the alternative to rotation. So the refresh token you hold stays valid, and concurrent refreshes are wasteful rather than destructive. Recommended-profile refresh tokens live as long as the grant does.

Because they live as long as the grant, a refresh that fails with invalid_grant is reported as %OpenFeed.Error{kind: :grant_revoked} rather than :unauthorized. Revocation is lazy — OpenFeed sweeps the refresh tokens bound to a grant when it is revoked, so this is how a client normally learns that a consumer withdrew consent.