X402.Extensions.AuthHints (X402 v0.9.0)

Copy Markdown View Source

The x402 auth-hints extension: per-requirement authentication hints.

Implements the auth-hints extension. A resource server whose accepts[] entries require authentication maps them to the authentication methods that satisfy them under PaymentRequired.extensions["auth-hints"], so a client can register and obtain credentials before submitting a payment instead of discovering the requirement through a 401:

%{
  "auth-hints" => %{
    "info" => %{
      "authRequirements" => [
        %{"acceptIndexes" => [1], "methods" => [%{"type" => "oauth2", ...}]}
      ]
    },
    "schema" => %{...}
  }
}

Two method types are defined: oauth2 (oauth2/1), carrying the token type and the authorization server's endpoints, and sign-in-with-x (sign_in_with_x/0), a pointer to the sign-in-with-x extension on the same response (X402.Extensions.SIWX). Unknown types are preserved on decode so clients can skip what they do not support.

Server

Advertise hints from a gate route with X402.Extensions.AuthHints.Adapter or build the value yourself with extension/1:

X402.Extensions.AuthHints.extension([
  [accept_indexes: [1], methods: [X402.Extensions.AuthHints.oauth2(
    token_type: "DPoP",
    authorization_server: "https://as.example.com",
    token_endpoint: "https://as.example.com/token",
    registration_endpoint: "https://as.example.com/register"
  )]]
])

The hints are discovery metadata only: validating the credentials the client then presents (Authorization, DPoP, SIGN-IN-WITH-X) stays with the application, and the facilitator is not involved.

Client

methods_for/2 returns the methods a chosen accepts[] entry requires — an empty list when it needs none — so a X402.Client.Hooks before_payment/2 implementation can complete the flow first:

def before_payment(%{payment_required: payment_required, requirements: chosen} = context, _meta) do
  case X402.Extensions.AuthHints.methods_for(payment_required, chosen) do
    [] -> {:ok, context}
    methods -> authenticate(methods, context)
  end
end

Indexes that fall outside accepts[] are silently ignored, as the specification requires of clients.

Summary

Types

Errors returned by decode/1 and validate_method/1.

A method map as it appears on the wire.

A decoded requirement.

Functions

Decodes the requirements from a PaymentRequired map (or its extensions map).

Builds the server-side advertisement for PaymentRequired.extensions.

Returns the extension key on the wire.

Returns the methods that satisfy authentication for one accepts[] entry, given by index or by the requirements map itself.

Builds an oauth2 method.

Returns whether an accepts[] entry (by index or map) requires authentication.

Returns the JSON schema the server advertises for the extension.

Builds a sign-in-with-x method: a pointer to the sign-in-with-x extension advertised on the same response.

Validates a method map as it appears on the wire.

Types

error()

@type error() ::
  :invalid_auth_hints
  | :invalid_accept_indexes
  | :invalid_method
  | {:missing_method_field, String.t()}
  | :invalid_token_type

Errors returned by decode/1 and validate_method/1.

method()

@type method() :: %{required(String.t()) => term()}

A method map as it appears on the wire.

requirement()

@type requirement() :: %{accept_indexes: [non_neg_integer()], methods: [method()]}

A decoded requirement.

Functions

decode(payment_required)

(since 0.9.0)
@spec decode(term()) :: {:ok, [requirement()]} | {:error, error()}

Decodes the requirements from a PaymentRequired map (or its extensions map).

Returns {:ok, []} when the extension is absent. Indexes outside the response's accepts[] are dropped (when the map given is the full PaymentRequired); a requirement left with no index is dropped too. Methods are checked with validate_method/1.

Examples

iex> extension = X402.Extensions.AuthHints.extension([
...>   [accept_indexes: [1, 7], methods: [X402.Extensions.AuthHints.sign_in_with_x()]]
...> ])
iex> payment_required = %{"accepts" => [%{}, %{}], "extensions" => %{"auth-hints" => extension}}
iex> X402.Extensions.AuthHints.decode(payment_required)
{:ok, [%{accept_indexes: [1], methods: [%{"type" => "sign-in-with-x"}]}]}

iex> X402.Extensions.AuthHints.decode(%{"accepts" => [], "extensions" => %{}})
{:ok, []}

iex> X402.Extensions.AuthHints.decode(%{"extensions" => %{"auth-hints" => %{"info" => %{"authRequirements" => "x"}}}})
{:error, :invalid_auth_hints}

iex> hints = %{"info" => %{"authRequirements" => [%{"acceptIndexes" => [0], "methods" => [%{"type" => "oauth2"}]}]}}
iex> X402.Extensions.AuthHints.decode(%{"accepts" => [%{}], "extensions" => %{"auth-hints" => hints}})
{:error, {:missing_method_field, "tokenType"}}

extension(auth_requirements)

(since 0.9.0)
@spec extension([keyword()]) :: map()

Builds the server-side advertisement for PaymentRequired.extensions.

Each requirement is a keyword list with :accept_indexes and :methods. Raises NimbleOptions.ValidationError for an invalid declaration, including an empty requirement list.

Requirement options

  • :accept_indexes - Required. Indexes into accepts[] that require authentication (at least one).

  • :methods - Required. Methods that satisfy the requirement (at least one), as built by oauth2/1 or sign_in_with_x/0.

Examples

iex> extension = X402.Extensions.AuthHints.extension([
...>   [accept_indexes: [1], methods: [X402.Extensions.AuthHints.sign_in_with_x()]]
...> ])
iex> extension["info"]
%{"authRequirements" => [%{"acceptIndexes" => [1], "methods" => [%{"type" => "sign-in-with-x"}]}]}
iex> extension["schema"] == X402.Extensions.AuthHints.schema()
true

extension_key()

(since 0.9.0)
@spec extension_key() :: String.t()

Returns the extension key on the wire.

Examples

iex> X402.Extensions.AuthHints.extension_key()
"auth-hints"

methods_for(payment_required, index)

(since 0.9.0)
@spec methods_for(map(), non_neg_integer() | map()) :: [method()]

Returns the methods that satisfy authentication for one accepts[] entry, given by index or by the requirements map itself.

Methods from every requirement naming the entry are concatenated in order. Returns [] when no authentication is needed, when the entry is not found, or when the extension is malformed — the client then proceeds without credentials and the server answers as it would for any unauthenticated request.

Examples

iex> oauth2 = X402.Extensions.AuthHints.oauth2(token_type: "DPoP",
...>   authorization_server: "https://as.example.com", token_endpoint: "https://as.example.com/token")
iex> extension = X402.Extensions.AuthHints.extension([[accept_indexes: [1], methods: [oauth2]]])
iex> exact = %{"scheme" => "exact", "network" => "eip155:8453"}
iex> deferred = %{"scheme" => "deferred", "network" => "eip155:8453"}
iex> payment_required = %{"accepts" => [exact, deferred], "extensions" => %{"auth-hints" => extension}}
iex> X402.Extensions.AuthHints.methods_for(payment_required, deferred) == [oauth2]
true
iex> X402.Extensions.AuthHints.methods_for(payment_required, 1) == [oauth2]
true
iex> X402.Extensions.AuthHints.methods_for(payment_required, exact)
[]
iex> X402.Extensions.AuthHints.methods_for(payment_required, 5)
[]

oauth2(opts)

(since 0.9.0)
@spec oauth2(keyword()) :: method()

Builds an oauth2 method.

Raises NimbleOptions.ValidationError for invalid options: a malformed hint is a configuration error.

Options

  • :token_type - Required. How the access token is presented: "Bearer" or "DPoP".

  • :authorization_server - Required. Base URL of the authorization server.

  • :token_endpoint - Required. Token endpoint URL.

  • :registration_endpoint - RFC 7591 dynamic client registration endpoint, when registration is available.

Examples

iex> X402.Extensions.AuthHints.oauth2(
...>   token_type: "DPoP",
...>   authorization_server: "https://as.example.com",
...>   token_endpoint: "https://as.example.com/token",
...>   registration_endpoint: "https://as.example.com/register"
...> )
%{
  "type" => "oauth2",
  "tokenType" => "DPoP",
  "authorizationServer" => "https://as.example.com",
  "tokenEndpoint" => "https://as.example.com/token",
  "registrationEndpoint" => "https://as.example.com/register"
}

iex> X402.Extensions.AuthHints.oauth2(
...>   token_type: "Bearer",
...>   authorization_server: "https://as.example.com",
...>   token_endpoint: "https://as.example.com/token"
...> )
%{
  "type" => "oauth2",
  "tokenType" => "Bearer",
  "authorizationServer" => "https://as.example.com",
  "tokenEndpoint" => "https://as.example.com/token"
}

requires_auth?(payment_required, index_or_requirements)

(since 0.9.0)
@spec requires_auth?(map(), non_neg_integer() | map()) :: boolean()

Returns whether an accepts[] entry (by index or map) requires authentication.

Examples

iex> extension = X402.Extensions.AuthHints.extension([
...>   [accept_indexes: [0], methods: [X402.Extensions.AuthHints.sign_in_with_x()]]
...> ])
iex> payment_required = %{"accepts" => [%{"scheme" => "exact"}, %{"scheme" => "upto"}], "extensions" => %{"auth-hints" => extension}}
iex> X402.Extensions.AuthHints.requires_auth?(payment_required, 0)
true
iex> X402.Extensions.AuthHints.requires_auth?(payment_required, %{"scheme" => "upto"})
false

schema()

(since 0.9.0)
@spec schema() :: map()

Returns the JSON schema the server advertises for the extension.

Examples

iex> schema = X402.Extensions.AuthHints.schema()
iex> schema["required"]
["authRequirements"]
iex> get_in(schema, ["properties", "authRequirements", "items", "required"])
["acceptIndexes", "methods"]

sign_in_with_x()

(since 0.9.0)
@spec sign_in_with_x() :: method()

Builds a sign-in-with-x method: a pointer to the sign-in-with-x extension advertised on the same response.

Examples

iex> X402.Extensions.AuthHints.sign_in_with_x()
%{"type" => "sign-in-with-x"}

validate_method(method)

(since 0.9.0)
@spec validate_method(term()) :: {:ok, method()} | {:error, error()}

Validates a method map as it appears on the wire.

oauth2 must carry a tokenType of Bearer or DPoP and string authorizationServer and tokenEndpoint values; sign-in-with-x needs only its type. Other types are accepted as long as type is a string, so clients can ignore methods they do not implement.

Examples

iex> X402.Extensions.AuthHints.validate_method(%{"type" => "sign-in-with-x"})
{:ok, %{"type" => "sign-in-with-x"}}

iex> X402.Extensions.AuthHints.validate_method(%{"type" => "oauth2", "tokenType" => "Bearer",
...>   "authorizationServer" => "https://as.example.com"})
{:error, {:missing_method_field, "tokenEndpoint"}}

iex> X402.Extensions.AuthHints.validate_method(%{"type" => "oauth2", "tokenType" => "MAC",
...>   "authorizationServer" => "https://as.example.com", "tokenEndpoint" => "https://as.example.com/token"})
{:error, :invalid_token_type}

iex> X402.Extensions.AuthHints.validate_method(%{"type" => "future"})
{:ok, %{"type" => "future"}}

iex> X402.Extensions.AuthHints.validate_method(%{"tokenType" => "Bearer"})
{:error, :invalid_method}