Credential resolution, the per-run access-token cache, and the opt-in
store-backed token keeper. Usually used indirectly through
::xero::api.
Sources. Per-call opts override context, which is how one run
acts for many users:
| Credential | opts keys | Context variables |
|---|---|---|
| Access token | access-token | xero.access.token |
| Token keeper | token-store (+ token-store-key) | xero.token.store, xero.token.store-key |
| Refresh token | refresh-token | xero.refresh.token |
| Custom Connection | custom-connection: true (+ scopes) | xero.custom-connection, xero.scopes |
| App | client-id, client-secret | xero.client.id, xero.client.secret |
When opts carries a primary credential (access-token,
token-store, refresh-token or custom-connection), context
credentials are ignored for the call; the app's client id and secret
still fall back to context. Order within a source: access token,
keeper, refresh token, Custom Connection. A credential key in opts
that is blank or null is an Err naming the key, never a fallback to
context. Per-call credentials are marked, so they are masked in call
records.
Run cache. Refreshed and client-credentials tokens are cached in
the run's context (as secrets) until a minute before expiry, with
the newest refresh token, so later refreshes in the same run use the
rotated value. Xero rotates the refresh token on every refresh, so
without the keeper call current-refresh-token(opts) and persist it
before the run ends; the old value only works for 30 more minutes.
Token keeper (opt-in). Set xero.token.store (or
opts.token-store) to a ::hot::store Map name. Entries are keyed
by token-store-key (default "default"). Key by the user
authorisation, not the tenant: one refresh token covers every
organisation the user connected, so two tenant entries holding copies
of it would invalidate each other on rotation. The keeper reuses a
fresh access token, otherwise refreshes and writes the new tokens
back with ::hot::store/put-if.
Race note. Two runs that find the same stale entry both refresh
with the same refresh token. Within Xero's 30-minute grace period
both succeed, but each gets a different new refresh token; put-if
keeps exactly one (the loser re-reads and uses the winner's tokens).
The loser's refresh token is discarded, which is safe because the
stored one is the newest. For heavy concurrency, refresh from a
single scheduled function. Store values hold tokens in the platform
store; restrict who can read it.
Context Vars
xero.access.tokenxero.refresh.tokenxero.client.idxero.client.secretxero.custom-connectionxero.scopesxero.token.storexero.token.store-key
Values
CACHE_PREFIX
CACHE_PREFIX: Str "xero.token-cache."
CREDENTIAL_KEYS
CREDENTIAL_KEYS: Vec [
"access-token",
"refresh-token",
"client-id",
"client-secret",
"custom-connection",
"scopes",
"token-store",
"token-store-key",
"tenant-id",
"if-modified-since",
"idempotency-key",
"retry",
"idempotent"
]
DEFAULT_STORE_KEY
DEFAULT_STORE_KEY: Str "default"
EXPIRY_MARGIN_SECONDS
EXPIRY_MARGIN_SECONDS: Int 60
NO_FALLBACK_KEYS
NO_FALLBACK_KEYS: Vec [
"access-token",
"refresh-token",
"token-store",
"token-store-key",
"client-id",
"client-secret"
]
Functions
access-token
fn (): Str
fn (opts: Map): Str
An access token for a call from opts and context (refreshing and caching as needed), or a TokenError.
app-opts
fn (creds: Map): Map
bearer
fn (creds: Map): Map
Resolve a bearer token for resolved credentials with the run cache: {token, cache-key, cached} or a TokenError.
cache-key
fn (creds: Map): Str?
The run-context key a token is cached under (hashed), or null for explicit access tokens.
credentials-from
fn (source: Map): Map
Turn a source Map into resolved credentials: {kind: "token"},
{kind: "keeper", store, key, seed-refresh-token, ...},
{kind: "refresh", refresh-token, ...} or {kind: "client-credentials", scopes, ...}, or a TokenError. Pure.
ctx-source
fn (): Map
The credential source read from context, in the opts shape.
current-refresh-token
fn (): Str?
fn (opts: Map): Str?
The newest refresh token known to this run for the credentials in
opts / context: the rotated value after a refresh in this run, else
the configured one (null for access tokens and Custom Connections).
Persist it before the run ends when you are not using the keeper.
evict
fn (creds: Map, cache-key: Str?): Bool
Expire a cached access token (after a 401), keeping the newest refresh token; also expires a keeper entry's access token.
has-primary-credential
fn (source: Map): Bool
True when source carries access-token, token-store, refresh-token or custom-connection: true.
is-fresh
fn (entry: Any, now: Int): Bool
True when an entry has an access token before its margin-adjusted expiry.
is-true
fn (value: Any): Bool
keeper-entry
fn (store-name: Str, key: Str): Map?
Read a keeper entry (null when absent). It holds tokens: never log it.
keeper-mint
fn (creds: Map, now: Int): Map
A fresh entry for keeper credentials: the stored one when fresh, else refresh and write back with put-if (re-reading on a lost race).
keeper-seed
fn (store-name: Str, key: Str, tokens: Str | Map): Map
Seed (or replace) a keeper entry, e.g. right after
::xero::oauth/exchange-code. Pass the TokenSet to store its
access token too, or just a refresh token Str. Returns the entry.
tokens ::xero::oauth/exchange-code(cb.code, redirect-uri)
::xero::auth/keeper-seed("xero-tokens", user-id, tokens)
mark-entry
fn (entry: Any): Any
Mask the tokens in a keeper entry and return it unchanged. Null passes through. Exposed for testing.
merge-sources
fn (opts: Map, ctx: Map): Map
Pick the source for a call: opts when it has a primary credential (client id and secret still fall back to ctx), else ctx. Pure.
mint-entry
fn (creds: Map, cached: Any, now: Int): Map
newest-refresh-token
fn (entry: Any, fallback: Str?): Str?
The refresh token to use: the entry's (newest) one, else the fallback.
refresh-into-entry
fn (creds: Map, refresh-token: Str, now: Int): Map
resolve-credentials
fn (opts: Map): Map
Resolve the credentials for a call from opts and context, marking per-call credentials so they are masked in call records. A credential key in opts that is blank or null is a TokenError naming the key.
store-map
fn (name: Str)
token-entry
fn (tokens: Map, now: Int): Map
The cached or stored form of a TokenSet: {access-token, expires-at, refresh-token, updated-at}, expires-at pulled in by EXPIRY_MARGIN_SECONDS.