Intuit OAuth 2.0 for QuickBooks Online, built on hot.dev/oauth2.
Flow
- Redirect the user to
authorize-url(...). Intuit asks them to pick a company and approve your app. - Intuit calls your redirect URI with
?code=...&state=...&realmId=....callback-params(request)reads them; checkstateagainst the value you issued. exchange-code(code, redirect-uri)returns aTokenSet.- Persist
refresh-tokenandrealm-idper 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
quickbooks.client.idquickbooks.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.
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 sentrotated- true whenrefresh-tokendiffers from the token sentexpires-in,expires-at- access-token lifetime (seconds) and expiry (epoch ms)refresh-token-expires-in,refresh-token-expires-at- Intuit'sx_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 ittoken-type,id-token(OpenID scopes only),scope