0006. OAuth token management (config token shape + refresh-before-execute)¶
- Status: Accepted
- Date: 2026-06-09
- Deciders: POS Integration team
Context¶
Several POS providers authenticate via OAuth 2.0 — iSeller, Lightspeed, Atlas
— unlike the static-credential providers (Revel uses api_key + secret). OAuth
requires obtaining tokens (an authorization-code browser grant, or a
client-credentials exchange), storing access/refresh tokens, and refreshing them
before they expire.
Today the service only has the label AuthTypeOAuth = "oauth"
(internal/domain/provider.go) — no token exchange, storage, or refresh.
iSeller access tokens expire in ~14 days (expires_in ≈ 1209599); client-credentials
tokens can simply be re-minted. We need one reusable mechanism so adapter scripts
always run with a valid bearer token, and so secrets never reach the JS sandbox.
Phase 1 sidesteps this by minting a client-credentials token inside the adapter script on each run — fine for client-credentials, but it doesn't cover the authorization-code flow (which needs a stored refresh token and a browser grant) and re-mints on every run.
Decision¶
Adopt a server-side OAuth token-management mechanism with four parts:
- Token storage in
provider_connections.config— a typed JSON shape, no new table:{ accessToken, refreshToken, expiresAt, resourceUrl }. Theclient_id/client_secretstay inapiKey/apiSecret. - Refresh-before-execute token manager (Go). Before running an
oauthprovider's adapter: checkexpiresAt; if missing/near expiry, refresh (authorization-code →grant_type=refresh_token; client-credentials → re-mint), persist the new token toconfig, and inject the currentaccessTokeninto the adapter input (input.config.accessToken;baseUrlfromresourceUrl). Mirrors the legacy Deliverect in-memory token cache. - Per-provider OAuth config registry (not per-tenant): authorize URL, token
URL, supported grant type(s), scopes, redirect URI, and whether the flow
returns a base/
resource_url. - OAuth callback endpoint for the authorization-code flow (exchange
code→ tokens, persist toconfig), with the frontend Connect button.
Secrets (client_id/client_secret, tokens) are resolved and refreshed in Go and
are never exposed to the goja sandbox — the script only ever sees the current
accessToken.
Consequences¶
Positive
- One reusable mechanism for every OAuth provider (iSeller, Lightspeed, Atlas).
- No schema migration — reuses the existing
configcolumn. - Scripts stay simple (
input.config.accessToken); secrets stay server-side. - Works for both grant types.
Negative / costs
- A token manager + per-provider registry + callback to build (tracked: INTR-766 and subtasks INTR-767–770).
configJSON is untyped at the DB layer (typed only in Go) — needs validation.- Refresh adds a pre-execution step and possible latency; concurrent runs refreshing the same connection need a guard (single-flight / row lock).
Alternatives considered¶
- Dedicated
oauth_tokenstable — cleaner typing/indexing, but a migration + more plumbing;configJSON suffices initially. - Refresh inside the JS adapter — self-contained (Phase 1 client-credentials
does this), but it exposes
client_secretto the sandbox and can't safely hold refresh tokens. Rejected for production / authorization-code. - No storage, mint per run — fine for client-credentials, impossible for authorization-code (no browser grant per run). Rejected as the general solution.
References¶
- INTR-766 (OAuth Connect UI + token refresh) and subtasks: INTR-767 (token shape), INTR-768 (token manager), INTR-769 (provider OAuth registry), INTR-770 (callback + Connect button), INTR-771 (this ADR).
adapters/iseller.md— OAuth (client-credentials- authorization-code), ~14-day expiry, token-host ≠ API-host.
adapters/lightspeed.md—oauth.internal/domain/provider.go(AuthTypeOAuth);adapter-script-contracts.md(input.config.accessToken/refreshToken).- Legacy Deliverect token cache in
kds-management-service.