Xero OAuth 2.0, built on hot.dev/oauth2.

Flow (authorization code)

  1. Redirect the user to authorize-url(...) with the scopes you need (include offline_access to get a refresh token; see ::xero/SCOPES_FINANCE_OPS).
  2. In the callback, check state, then exchange-code(code, redirect-uri) returns a TokenSet.
  3. connections(tokens.access-token) lists the organisations the user authorised; each has a tenantId to send as xero-tenant-id.
  4. Persist the refresh token. Access tokens last 30 minutes.

Rotation. Xero rotates the refresh token on every refresh: the response carries a new one, and the old one stops working (Xero allows a 30-minute grace period to retry with it). An unused refresh token expires after 60 days. Every TokenSet carries the token to keep in refresh-token; always persist it, or let the ::xero::auth token keeper do it in a ::hot::store. One refresh token covers every organisation the user connected.

Custom Connections. A Custom Connection is a paid, single- organisation machine-to-machine app: client-credentials(scopes) gets a 30-minute token with no refresh token and no tenant header.

Token calls authenticate with HTTP Basic (client_secret_basic). PKCE apps (no client secret) send client_id in the body instead.

Context Vars

Optional
  • xero.client.id
  • xero.client.secret

Functions

app-credentials

fn (opts: Map, require-secret: Bool): 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. The secret may be null for PKCE apps unless require-secret is true. A key in opts that is blank or null is a TokenError, never a fallback to context; a PKCE app leaves client-secret out.

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

authorize-url

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

Build Xero's consent URL. scopes is a Vec or space-separated Str (::xero/SCOPES_FINANCE_OPS is a good start; include offline_access for a refresh token); state is your CSRF token. Options: code-verifier (its S256 challenge is sent; keep the verifier for exchange-code), code-challenge, extra.

url ::xero::oauth/authorize-url("CLIENT-ID", "https://app.example.com/oauth/xero", ::xero/SCOPES_FINANCE_OPS, "csrf-123")

basic-auth

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

Basic base64(client-id:client-secret) for token calls.

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-client-credentials-body

fn (scope: Str): Map

The client_credentials grant body for a Custom Connection; scope may be empty to get every scope the connection was granted.

build-exchange-body

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

The authorization_code grant body; code-verifier completes a PKCE flow.

build-refresh-body

fn (refresh-token: Str): Map

The refresh_token grant body.

build-revoke-request

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

Build the revocation HttpRequest (RFC 7009: form token=..., Basic client auth). Pure.

build-token-request

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

Build (without sending) the token-endpoint HttpRequest (pure): a form-encoded grant body, with HTTP Basic client auth when there is a secret, or client_id in the body for PKCE apps.

callback-params

fn (request: Map): Map

Read Xero's redirect: {code, state, error, error-description} from the request query (null when absent).

client-credentials

fn (scopes: Str | Vec): TokenSet
fn (scopes: Str | Vec, opts: Map): TokenSet

Get a Custom Connection token (client credentials grant). scopes (Vec or Str) may be empty to receive every scope the connection was granted. No refresh token; ask again when it expires (30 minutes). Options: client-id, client-secret.

connections

fn (access-token: Str): Vec
fn (access-token: Str, auth-event-id: Str?): Vec

GET /connections - the organisations (tenants) an access token can reach: [{id, tenantId, tenantType, tenantName, authEventId, createdDateUtc, updatedDateUtc}]. Send a tenantId as xero-tenant-id (xero.tenant.id or opts.tenant-id). auth-event-id (from the access token's authentication_event_id claim) limits the list to the organisations just connected.

delete-connection

fn (access-token: Str, connection-id: Str): Bool

DELETE /connections/{id} - disconnect one organisation (the connection id, not the tenant id). Returns true, or a TokenError.

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. Options: client-id, client-secret (else context; omit the secret for PKCE apps), code-verifier.

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.

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 (duplicates removed); "" for none.

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). Xero returns a new refresh token every time: persist tokens.refresh-token before using the access token. A reused or expired refresh token gives error-code: "invalid_grant"; the user must connect again. Options: client-id, client-secret.

revoke

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

Revoke a refresh token: every connection it authorised is removed. Returns true, or a TokenError.

send-token-request

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

token-error

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

Build a TokenError from a message, or from a prefix and a failed token response ({status, body}) or transport error.

token-set

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

Turn a token response into a TokenSet (pure). sent-refresh-token is the token the request used (null otherwise); now is epoch ms.

without-client

fn (body: Map): Map

Types

TokenError

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

A credential or token-endpoint failure. message is readable, e.g. "xero: token refresh failed: invalid_grant"; error-code and error-description carry the OAuth error / error_description. Token values never appear in messages.

TokenSet

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

The result of a code exchange, refresh or client-credentials grant.

  • access-token - the bearer token (30 minutes)
  • refresh-token - the refresh token to persist (a new one on every refresh; null for Custom Connections)
  • rotated - true when refresh-token differs from the one sent
  • expires-in, expires-at - lifetime (seconds) and expiry (epoch ms)
  • token-type, id-token (OpenID scopes), scope