Inkress ADRsarchitecture decisions
Overview / Identity / auth
ADR 0027

A first-party OAuth 2.1 + PKCE provider for third-party developer apps

AcceptedIdentity / auth

Context

Partner and third-party apps need to act on a merchant's behalf without ever seeing the merchant's password or API keys, with per-app consent, scoping, and revocation. The options were to adopt an external identity provider, hand out static per-partner keys, or build the provider in-house. Because the authorization boundary sits directly on the money surface, we wanted to own the scope model rather than delegate it.

Decision

Implement a first-party OAuth 2.1 provider natively in commerce-api — Authorization Code with PKCE (S256 only) — with a staff-gated developer-app registry and a code-defined scope registry.

  • Clients and tokens. A developer app is an oauth_clients row that requires staff approval before it can be used. Tokens are opaque and prefix-typed: inka_ access (short-lived), inkr_ refresh (single-use, rotating, with family-based theft detection — a reused refresh burns the whole family), inkc_ authorization codes (single-use, PKCE-bound). The provider serves RFC 8414 discovery and RFC 7009 revocation.
  • Scopes are code-defined and capped. A canonical registry (Api.Auth.Scopes) maps user-facing scope strings onto fine-grained {resource, action} permission tuples, capped by the merchant_admin ceiling and deliberately narrower than a merchant's own dashboard access — no /me, and payments:read is reserved → [] so the raw ledger is never exposed over OAuth. Adding or widening a scope is a code change + review, not a DB row — deliberate friction on the external attack surface.
  • Tokens carry no user identity. A token binds to (merchant, client, scopes), so it keeps working after the consenting user leaves. Consent "is live" is represented by an active token row, not a separate consents table.
  • Embedded apps bootstrap by token exchange. A dashboard-embedded app receives a 60-second HS256 session JWT signed with the app's own webhook secret (whsec_), which it can verify offline, and exchanges it at the token endpoint via RFC 8693 token-exchange for a normal inka_ token (scopes = requested ∩ JWT ∩ client-allowed; jti claimed once in Redis).
  • Client secrets are HMAC-SHA256 + a server pepper, not bcrypt/argon — a deliberate choice for a high-entropy random secret that needs fast constant-time verification, not slow password hashing.

Consequences

  • Positive: a standards-compliant partner surface (discovery / consent / refresh / revoke) with reuse-detection, under our own scope model; partners never hold merchant credentials.
  • Positive: least-privilege by construction — the OAuth surface can only ever be narrower than a merchant's own access, and can't be widened without a reviewed code change.
  • Neutral / to revisit: scope changes require a deploy (no runtime scope editing) — intentional, but it means partner-requested capabilities move at release cadence.
  • Neutral / to revisit: the embedded-app iframe/bridge surface is staged ("ship next"); the auth substrate (token exchange, session JWT) is live ahead of the full embed UX.

Alternatives considered

  • An external IdP (Auth0/Okta/Keycloak): rejected — it surrenders control of the merchant-scope model and adds a vendor dependency right at the money boundary.
  • Static per-partner API keys: rejected — no consent granularity, no per-scope least privilege, no clean revocation; a partner key is all-or-nothing.
  • DB-driven scope→permission rows: rejected — it would let the external attack surface widen without review; the scope registry lives in code precisely so expansion is gated.
← 0026 API authentication accepts many credential types through one resolution pipeline 0028 Service-to-service authentication uses three schemes, chosen per trust boundary →