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_clientsrow 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 themerchant_adminceiling and deliberately narrower than a merchant's own dashboard access — no/me, andpayments:readis 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 normalinka_token (scopes = requested ∩ JWT ∩ client-allowed;jticlaimed 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.