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
endIndexes 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
@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.
A method map as it appears on the wire.
@type requirement() :: %{accept_indexes: [non_neg_integer()], methods: [method()]}
A decoded requirement.
Functions
@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"}}
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 intoaccepts[]that require authentication (at least one).:methods- Required. Methods that satisfy the requirement (at least one), as built byoauth2/1orsign_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
@spec extension_key() :: String.t()
Returns the extension key on the wire.
Examples
iex> X402.Extensions.AuthHints.extension_key()
"auth-hints"
@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)
[]
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"
}
@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
@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"]
@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"}
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}