DpExchange.Schwab.Auth (DpExchangeSchwab v0.1.3)

Copy Markdown View Source

Signing requests, and refreshing the token that signs them — internal.

The line, and which side each half falls on

§6.0: credential storage is host-side and never leaves; credential use — signing, session refresh, token rotation — is venue strategy and crosses into the package. Schwab splits cleanly along that line, and the split is not academic:

The host's half is the browser. Schwab's initial grant is three-legged OAuth. It redirects a person to Schwab's login site, has them choose which accounts to share, and catches the redirect back. There is no headless path; something has to put a human in front of a login page. A library cannot do it and should not try.

This package's half is everything after. An access token is valid for 30 minutes. That is the number that decides this module's existence: a package that only signed, and handed an expired token back to the caller twice an hour, would be unusable for anything unattended. Refresh is a machine-to-machine POST with no human in it, and it is exactly the "session refresh, token rotation" §6.0 places on this side.

So: the host logs a person in once, and this package keeps that grant alive indefinitely — for as long as it is refreshed at least once a week.

The refresh token is one-time use, and every refresh issues a new one

This is the single most important fact about operating this venue, and getting it wrong is unrecoverable rather than inconvenient.

LifetimeRenewed by
access_token30 minutesrefresh/2, from the refresh token
refresh_token7 days from its own creationrefresh/2 — every call mints a new one, and the seven days restart with it

A refresh spends the token it was given. The old string is dead the moment the request succeeds, and the response's refresh_token is its replacement with a fresh seven days on it. So there is no weekly ceiling on unattended operation: a host refreshing every thirty minutes rolls the seven-day window forward every thirty minutes and never needs a person again. The clock only runs out if the package stops refreshing for a week.

Three consequences, and the code enforces all three:

A success without a refresh_token is an error, not a token to keep. The old one is already spent, so carrying it forward would hand the host a credential that is guaranteed to fail at the next refresh — days later, far from the cause, and by then unrecoverable without a person. refresh/2 returns {:error, :missing_rotated_refresh_token} instead.

A refresh is never retried. It is an at-most-once operation: if the request times out, the token may already have been spent server-side, and retrying with the same string will fail while the real new token sits in a response nobody read. Retries are forced off inside refresh/2 and cannot be re-enabled through options — a transport failure is returned for the caller to handle with the credential it still holds.

The result must be persisted before it is used. A host that refreshes and then crashes before storing the response has lost the account until a person logs in again. That is a real operational hazard rather than a style note, and it is why refresh/2 returns the whole credential rather than mutating anything.

Nothing is cached here. This module holds no state, writes nothing, and logs no token value; refresh/2 returns the new credential to the caller, which owns storage.

What ends a grant for good

Only two things: seven days with no refresh, or the user resetting their Schwab password. Both return {:refused, {:reauthorization_required, status, detail}}, which names the remedy — a person, at a browser — rather than an error a caller would retry against a credential that can never succeed.

Summary

Types

What the host supplies.

Functions

Whether a status means the credential is finished rather than the request.

Headers for an authenticated request.

Whether credentials should be refreshed before the next call.

Exchange a refresh token for a new access token.

Seconds of margin needs_refresh?/2 refreshes ahead of expiry.

Token endpoint, overridable for tests.

Types

credentials()

@type credentials() :: %{
  optional(:access_token) => String.t(),
  optional(:refresh_token) => String.t(),
  optional(:client_id) => String.t(),
  optional(:client_secret) => String.t(),
  optional(:expires_at) => DateTime.t(),
  optional(any()) => any()
}

What the host supplies.

:access_token is what signs. :refresh_token, :client_id and :client_secret are what refresh/2 needs; a credential without them can still sign, it just cannot renew itself. :expires_at is optional and lets needs_refresh?/2 answer without a failed request first.

Functions

credential_failure?(status)

@spec credential_failure?(pos_integer()) :: boolean()

Whether a status means the credential is finished rather than the request.

401 and 403 are not retryable with the same token. The caller's move is to refresh/2 and try once more; if the refresh itself is refused, a person must log in.

headers(credentials, opts \\ [])

@spec headers(
  credentials() | nil,
  keyword()
) :: {:ok, [{String.t(), String.t()}]} | {:error, term()}

Headers for an authenticated request.

{:error, {:missing_credentials, :schwab}} when there is no usable token, and no request is sent. There is no anonymous surface on this venue — market data included — so an unauthenticated request is not a degraded request, it is a guaranteed 401. Sending it would spend a rate-limit slot to learn something already known.

A blank token counts as missing: Bearer is not a credential, and sending it turns a local, nameable refusal into a remote one.

needs_refresh?(credentials, now \\ DateTime.utc_now())

@spec needs_refresh?(credentials(), DateTime.t()) :: boolean()

Whether credentials should be refreshed before the next call.

true when the token expires within 120 seconds, or has already expired. false when :expires_at is absent — an unknown expiry is not an expired one, and refreshing on every call because nobody said would burn the venue's token endpoint and, if the refresh token does rotate, churn the host's stored credential for no reason.

A host that does not track expiry still gets refreshed correctly; it just happens reactively, when a 401 arrives, rather than ahead of it.

refresh(credentials, opts \\ [])

@spec refresh(
  credentials(),
  keyword()
) :: {:ok, credentials()} | {:error, term()} | {:refused, term()}

Exchange a refresh token for a new access token.

Returns {:ok, credentials} carrying both new tokens plus an :expires_at derived from the venue's own expires_in, merged over whatever the caller passed in so the host's own bookkeeping survives the round trip.

The caller must persist the result before using it. The refresh token passed in has been spent by this call; the one returned is its only replacement. Losing it costs the grant, and recovering costs a person at a browser.

Never retried — see the module doc. Failure modes are deliberately distinct, because the remedies are:

  • {:error, {:missing_credentials, :schwab}} — no refresh token, or no client credentials to authenticate the refresh with. Nothing was sent, and nothing is spent.
  • {:refused, {:reauthorization_required, status, detail}} — Schwab rejected the refresh token. Terminal. Seven days elapsed with no refresh, or the user reset their password. Only a person at a browser can fix it; a caller must not retry.
  • {:error, :missing_rotated_refresh_token} — Schwab accepted the refresh and returned an access token but no replacement refresh token. Treated as a failure rather than a success, because the old token is already spent and reporting success would hand back a credential guaranteed to die at the next refresh.
  • {:error, {:exchange_error, :schwab, message}} — a 5xx, or a 4xx that is not a rejection of the credential. The grant is probably intact; the caller may try again with the credential it still holds.

refresh_margin_seconds()

@spec refresh_margin_seconds() :: pos_integer()

Seconds of margin needs_refresh?/2 refreshes ahead of expiry.

token_url(opts)

@spec token_url(keyword()) :: String.t()

Token endpoint, overridable for tests.