Intuit OAuth 2.0 for QuickBooks Online, built on hot.dev/oauth2.

Flow

  1. Redirect the user to authorize-url(...). Intuit asks them to pick a company and approve your app.
  2. Intuit calls your redirect URI with ?code=...&state=...&realmId=.... callback-params(request) reads them; check state against the value you issued.
  3. exchange-code(code, redirect-uri) returns a TokenSet.
  4. Persist refresh-token and realm-id per company. Access tokens last an hour; refresh tokens last 100 days from their last use and at most five years in all.

Rotation. Refresh tokens rotate: refresh can return a different refresh token than the one you sent, and once it does the old value stops working. Every TokenSet carries the refresh token to keep (refresh-token) and rotated: true when it changed. Always persist it, or let the ::quickbooks::auth token keeper do that in a ::hot::store.

Token calls authenticate the app with HTTP Basic (client_secret_basic), as Intuit's SDKs do. The client id and secret come from opts (client-id, client-secret) or the quickbooks.client.id / quickbooks.client.secret context variables.

::oauth ::quickbooks::oauth

url ::oauth/authorize-url("ABcd123", "https://app.example.com/oauth/quickbooks", "csrf-123")

// In the callback handler:
cb ::oauth/callback-params(request)
tokens ::oauth/exchange-code(cb.code, "https://app.example.com/oauth/quickbooks")
// persist tokens.refresh-token and cb.realm-id for this company

Context Vars

Optional
  • quickbooks.client.id
  • quickbooks.client.secret

Functions

app-credentials

fn (opts: Map): Map

The app's {client-id, client-secret} from opts, else context, with a per-call secret marked so it is masked in call records. A TokenError names what is missing; a key in opts that is blank or null is one too, never a fallback to context.

Context Vars: quickbooks.client.id, quickbooks.client.secret

authorize-url

fn (client-id: Str, redirect-uri: Str, state: Str): Str
fn (client-id: Str, redirect-uri: Str, state: Str, opts: Map): Str

Build Intuit's consent URL. state is your CSRF token (Intuit requires one). Options: scopes (Vec or Str; default com.intuit.quickbooks.accounting; add openid email for the user's identity), code-challenge (an S256 PKCE challenge), and extra (any other parameters).

url ::quickbooks::oauth/authorize-url("ABcd123", "https://app.example.com/oauth/quickbooks", "csrf-123",
  {scopes: ["com.intuit.quickbooks.accounting", "openid", "email"]})

basic-auth

fn (client-id: Str, client-secret: Str): Str

The Authorization header value for token calls: Basic base64(client-id:client-secret).

blank-option

fn (opts: Map, names: Vec): Str?

The first of names that opts has but with a blank or null value, else null. Such a key is an Err, never a fallback to context: a tenant whose credential is missing must not run on the project's. Exposed for testing.

build-exchange-body

fn (code: Str, redirect-uri: Str, code-verifier: Str?): Map

The authorization_code grant body (client credentials go in the Basic header). code-verifier completes a PKCE flow.

build-refresh-body

fn (refresh-token: Str): Map

The refresh_token grant body (client credentials go in the Basic header).

build-revoke-request

fn (token: Str, client-id: Str, client-secret: Str): ::hot::http/HttpRequest

Build the revoke HttpRequest (JSON {token}, Basic client auth). Pure; exposed for testing.

build-token-request

fn (body: Map, client-id: Str, client-secret: Str): ::hot::http/HttpRequest

Build (without sending) the token-endpoint HttpRequest: a form-encoded grant body, HTTP Basic client authentication, and the x-include-refresh-token-hard-expires-in header so Intuit reports the five-year refresh-token limit. Pure; exposed for testing.

callback-params

fn (request: Map): Map

Read Intuit's redirect: {code, state, realm-id, error, error-description} from the request's query (fields are null when absent). realm-id is the company the user picked; store it next to the refresh token. Compare state with the value you issued before exchanging the code.

exchange-code

fn (code: Str, redirect-uri: Str): TokenSet
fn (code: Str, redirect-uri: Str, opts: Map): TokenSet

Exchange the authorization code for a TokenSet. redirect-uri must match the one used for authorize-url. Options: client-id, client-secret (else context), code-verifier (PKCE).

int-or-null

fn (value: Any): Int?

An Int from an Int or digit-only Str; null otherwise. Guards Int() so bad input never halts a run.

is-blank

fn (value: Any): Bool

True for null, "" and whitespace-only strings (unset env placeholders arrive as "").

mark-tokens

fn (body: Map): Map

Mask the tokens in a token-endpoint response and return it unchanged. Only the tokens: marking the whole body would also hide values like token_type and scope. Exposed for testing.

normalize-scopes

fn (scopes: Any): Str

Scopes as a space-separated Str from a Vec or a space/comma-separated Str; SCOPE_ACCOUNTING when null or empty.

now-ms

fn (): Int

refresh

fn (refresh-token: Str): TokenSet
fn (refresh-token: Str, opts: Map): TokenSet

Exchange a refresh token for a new TokenSet (no cache). Persist the returned refresh-token: Intuit rotates it, and the old value stops working once a new one is issued. A revoked or expired refresh token gives error-code: "invalid_grant"; the company must connect again. Options: client-id, client-secret (else context).

tokens ::quickbooks::oauth/refresh(stored.refresh-token)
if(tokens.rotated, save-refresh-token(realm-id, tokens.refresh-token), null)

revoke

fn (token: Str): Bool
fn (token: Str, opts: Map): Bool

Revoke a refresh or access token, disconnecting the app from the company (revoking the refresh token also ends its access tokens). Returns true, or a TokenError. Options: client-id, client-secret.

send-token-request

fn (req: ::hot::http/HttpRequest, prefix: Str): Map

Send a token request; the decoded body on 2xx with its tokens marked so they are masked in call records, else a TokenError prefixed with prefix.

token-error

fn (message: Str): TokenError
fn (prefix: Str, payload: Any): TokenError

Build a TokenError. The 1-arity form wraps a message; the 2-arity form describes a failed token response ({status, body, headers}) or transport error, prefixed with prefix.

token-set

fn (body: Map, sent-refresh-token: Str?, now: Int): TokenSet

Turn Intuit's token response into a TokenSet (pure). sent-refresh-token is the refresh token the request used (null for a code exchange): when Intuit's response has no refresh_token the sent one is kept, and rotated says whether the value changed. now is epoch ms.

without-client

fn (body: Map): Map

Types

TokenError

TokenError type {
    message: Str,
    status: Int?,
    error-code: Str?,
    error-description: Str?,
    intuit-tid: Str?
}

A credential or token-endpoint failure. message is readable, e.g. "quickbooks: token refresh failed: invalid_grant (Token invalid)". error-code / error-description carry Intuit's OAuth error / error_description, and intuit-tid the response's intuit_tid header for support requests. Token values never appear in messages.

TokenSet

TokenSet type {
    access-token: Str,
    refresh-token: Str?,
    rotated: Bool,
    expires-in: Int?,
    expires-at: Int?,
    refresh-token-expires-in: Int?,
    refresh-token-expires-at: Int?,
    refresh-token-hard-expires-in: Int?,
    token-type: Str?,
    id-token: Str?,
    scope: Str?
}

The result of a code exchange or refresh.

  • access-token - the bearer token (about an hour)
  • refresh-token - the refresh token to persist; it may differ from the one you sent
  • rotated - true when refresh-token differs from the token sent
  • expires-in, expires-at - access-token lifetime (seconds) and expiry (epoch ms)
  • refresh-token-expires-in, refresh-token-expires-at - Intuit's x_refresh_token_expires_in (rolling, about 100 days)
  • refresh-token-hard-expires-in - the absolute five-year limit (x_refresh_token_hard_expires_in), when Intuit returns it
  • token-type, id-token (OpenID scopes only), scope