PhoenixKit.API.Auth (phoenix_kit v2.64.1)

Copy Markdown View Source

Plug that admits a request to the machine API: steps 1 to 3 of the request flow (API switch, refusal of a credential in the URL, authentication; PhoenixKit.API.Executor runs the rest). The order below is the order of the code.

Every transport mounts it first, with the audience of the endpoint:

plug PhoenixKit.API.Auth, audience: :admin

In order, it:

  1. assigns :phoenix_kit_api_request_id: the x-request-id response header when Plug.RequestId set one (an id the client sent is reused there) and it matches \A[A-Za-z0-9_=+/-]+\z; any other id, and no id at all, gets a fresh random one. The id goes into problem bodies and log lines, so nothing else is let through;
  2. answers 404 when the audience's switch is off (PhoenixKit.API.Config.admin_enabled?/0, public_enabled?/0; a settings read error counts as off), and, for the public audience, while the website password gate is on and the client's address is not on the gate's allow-list. Nothing else is done for such a request: no database read beyond the cached settings;
  3. refuses a credential in the URL with 400 token_in_url, before any authentication (token_in_url?/1);
  4. for the admin audience, authenticates the Authorization: Bearer pk_... key with PhoenixKit.API.Keys.verify/2 (always, whatever the state of the IP's failure bucket). For the public audience it assigns an anonymous actor.

An optional :deprecated option ([path: ..., successor: ...], set by the forward of a deprecated path, see PhoenixKit.API.Paths) marks every answer from step 3 on with Deprecation and a successor Link. The bare 404 of step 2 does not carry it: a switched-off or gated API reveals nothing.

An optional :before_auth hook, {module, function}, is called with the conn after step 3 and before step 4. The MCP transport uses it to refuse a foreign Origin. The function must return a conn; a halted conn ends the request, so a refusal costs no key lookup and is not charged to the failed-authentication bucket. A hook that raises answers 500 internal_error.

On success it assigns :phoenix_kit_api_actor, a PhoenixKit.API.Actor whose scopes are the key's effective scopes of this request. On failure it sends a problem document and halts: 401 unauthorized for a missing, malformed, unknown, revoked or expired key or a dead owner (one answer for all of them, so a probe learns nothing), 403 ip_not_allowed for a key used from outside its allowed addresses.

Failed-authentication throttle

Failures are counted per client IP (PhoenixKit.API.RateLimit), and only after the key has been verified and rejected: the plug asks RateLimit.check_preauth/1 (a peek that charges nothing) after a failure. If the IP's bucket is exhausted the failure is answered 429 rate_limited and is not charged again; otherwise it is charged (RateLimit.record_auth_failure/1) and answered 401 or 403. A request with a valid key never reaches that code, so a neighbour guessing keys from the same address cannot lock the accounting system out. The throttle limits the answers, not the lookups: every guess still costs one indexed read of the key table (an unknown prefix is never cached).

Summary

Functions

Whether name is the name of a credential: one of credential_params/0, or any name that ends in _token (access_token, session_token, claim_token), compared without regard to case. token_count and tokens_issued are not.

The credential names core knows by heart. credential_name?/1 adds every name that ends in _token.

Whether the website password gate hides the API of audience from the client of conn.

Whether the query string carries a credential: a parameter whose name is a credential (credential_name?/1: api_key, authorization, token, secret, anything ending in _token and so on; any case, with or without a [] suffix), or any parameter whose value starts with pk_ (any case, after trimming spaces).

Functions

credential_name?(name)

@spec credential_name?(term()) :: boolean()

Whether name is the name of a credential: one of credential_params/0, or any name that ends in _token (access_token, session_token, claim_token), compared without regard to case. token_count and tokens_issued are not.

The one rule behind the refusal of a credential in a URL and the redaction of a credential from a stored answer (PhoenixKit.API.Idempotency).

credential_params()

@spec credential_params() :: [String.t()]

The credential names core knows by heart. credential_name?/1 adds every name that ends in _token.

The boot-time log filter (PhoenixKit.boot/1) is built from this list, and Phoenix filters a parameter whose name contains one of its entries, so token also keeps every *_token out of the log: a name refused here never reaches the log in clear text.

gated?(conn, atom)

@spec gated?(Plug.Conn.t(), :admin | :public) :: boolean()

Whether the website password gate hides the API of audience from the client of conn.

While the gate is on, the public API is as private as the site: it would otherwise hand a gated pre-production site's catalogue and customer data to anyone. Addresses on the gate's allow-list pass, like they do for the site. The admin API is not gated: it has its own keys.

Only PhoenixKit.WebsiteAccess.Gate.enabled?/0 and the address list are asked: the gate's session helpers need a fetched session, which an API request does not have. Both reads have the semantics of the site gate itself: PhoenixKit.Settings folds a read error into the setting's default (gate off), exactly as PhoenixKitWeb.Plugs.WebsiteAccess does for the site, so the gate is not made stricter here than it is for the pages it protects. The API switch, which is read first, fails closed.

PhoenixKit.API.BodyGuard asks the same question, so a request this function turns away is not read or judged there either.

token_in_url?(conn)

@spec token_in_url?(Plug.Conn.t()) :: boolean()

Whether the query string carries a credential: a parameter whose name is a credential (credential_name?/1: api_key, authorization, token, secret, anything ending in _token and so on; any case, with or without a [] suffix), or any parameter whose value starts with pk_ (any case, after trimming spaces).

Names and values are compared after percent-decoding, so %61pi_key=... is caught too.