DpExchange.Coinbase.Auth (DpExchangeCoinbase v0.3.8)

Copy Markdown View Source

Coinbase CDP authentication — a signed Ed25519 JWT, built here rather than in the shared HTTP client.

This lived in Core.HttpClient as a :coinbase_cdp_jwt branch of a shared build_auth_headers/5. It is a venue fact, and a venue fact in shared code is a second place that can be wrong about the venue. Core keeps the generic schemes and takes a function for anything else; this is that function.

Why it is public, and the incident behind it

The WebSocket side needs the same token. It once had its own stub that returned the raw API key instead of a signed JWT, and Coinbase answers that with {"type":"error","message":"authentication failure"} — which is how the level2 channel produced nothing while ticker, which is public and needs no auth, worked fine. A venue half-delivering looks like a quiet market, not a broken credential.

A working implementation living one module away from a stub doing the same job is the kind of duplication that only ever shows up as a runtime failure. One implementation, used by both paths.

Summary

Functions

Builds a signed CDP JWT.

Builds authentication headers for a REST call.

Functions

jwt(credentials, opts \\ [])

@spec jwt(
  map(),
  keyword()
) :: {:ok, String.t()} | {:error, term()}

Builds a signed CDP JWT.

:uris

Scopes the token to specific REST calls, per Coinbase's documentation. The WebSocket token omits it — a socket is not one request — so the claim is included only when given rather than defaulted to something plausible. Defaulting it would produce a token that looks right and is rejected.

The two-minute expiry is deliberate

Short by design, and not cached: the streaming side rebuilds one per subscribe. A token that outlives its window fails in exactly the silent way the incident above was about — the connection is up, the subscribe is accepted, and no data arrives.

120 matches Coinbase's own documented default (docs/reference/coinbase/jwt-auth.md quotes "your JWT is only valid for a period of 2 minutes" verbatim), not a ceiling the venue enforces regardless of what a caller requests — the page states 120s as the SDK samples' default and leaves the actual duration to the caller. This package's own choice to use that default, for the reason above, is doc-derived rather than an arbitrary number that happens to match.

Credentials that cannot sign are refused here, by name

A map without :api_key and :api_secretnil, %{}, or one assembled with a typo'd key — answers {:error, {:missing_credentials, :coinbase}}, the same shape every other venue package in this family returns for the same condition.

This clause used to be absent, and credentials.api_key was read unconditionally, so the same input raised KeyError from inside signing instead. That reached every write endpoint through Rest.json_request/5, which — unlike Rest.request/5 — carries no nil guard of its own, and it surfaced as a crash in the caller's process rather than as the refusal the contract asks for.

rest_headers(method, path, body, credentials)

@spec rest_headers(atom(), String.t(), String.t() | nil, map()) ::
  {:ok, [{String.t(), String.t()}]} | {:error, term()}

Builds authentication headers for a REST call.

Returns {:ok, headers} or {:error, reason}, so a caller gates on it with with the way every other venue package in this family gates on its own Auth.headers.

Why this returns a tuple rather than a bare header list

An unsigned token is worse than none: it looks like a credential problem at the venue rather than at us, and sends the reader looking in the wrong place. That reason has always been right, but the shape this function used to have could not act on it.

It was written as Core.HttpClient's 4-arity auth hook, which may only return a header list — it has no way to say "abort, do not send". So on a signing failure it returned the content-type header alone and documented that "the caller decides whether an unauthenticated request is acceptable". No caller ever decided. Rest.request/5 and Rest.json_request/5 both handed whatever came back straight to HttpClient.request/5 without ever checking for Authorization.

For a GET on a public market-data path that is harmless — Coinbase serves those anonymously. For json_request/5 there is no public path: every caller of it is a write. So a malformed api_secret turned a live place_order/3 into an unauthenticated POST, sent to the venue to fail there as an opaque 401 — which is precisely the failure the paragraph above exists to prevent, produced by the mechanism that documented it.

Returning a tuple is what makes the stated rule true. The signing failure now stops the request here, locally, with the reason that caused it.