Skip to content

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:

  1. Token storage in provider_connections.config — a typed JSON shape, no new table: { accessToken, refreshToken, expiresAt, resourceUrl }. The client_id / client_secret stay in apiKey / apiSecret.
  2. Refresh-before-execute token manager (Go). Before running an oauth provider's adapter: check expiresAt; if missing/near expiry, refresh (authorization-code → grant_type=refresh_token; client-credentials → re-mint), persist the new token to config, and inject the current accessToken into the adapter input (input.config.accessToken; baseUrl from resourceUrl). Mirrors the legacy Deliverect in-memory token cache.
  3. 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.
  4. OAuth callback endpoint for the authorization-code flow (exchange code → tokens, persist to config), 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 config column.
  • 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).
  • config JSON 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_tokens table — cleaner typing/indexing, but a migration + more plumbing; config JSON suffices initially.
  • Refresh inside the JS adapter — self-contained (Phase 1 client-credentials does this), but it exposes client_secret to 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.mdoauth.
  • internal/domain/provider.go (AuthTypeOAuth); adapter-script-contracts.md (input.config.accessToken/refreshToken).
  • Legacy Deliverect token cache in kds-management-service.