DpExchange.Coinbase.Auth (DpExchangeCoinbase v0.1.1)

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, as Core.HttpClient's auth hook.

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.

rest_headers(method, path, body, credentials)

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

Builds authentication headers for a REST call, as Core.HttpClient's auth hook.

Pass it where an auth type would go:

HttpClient.build_auth_headers(:get, path, body, credentials, &Auth.rest_headers/4)

On a signing failure it returns the content-type header alone rather than an unsigned Authorization. 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. The caller decides whether an unauthenticated request is acceptable.