Overview / Identity / auth
ADR 0026
API authentication accepts many credential types through one resolution pipeline
AcceptedIdentity / auth
Context
commerce-api serves dashboard users, storefronts, merchant API integrations, chatbots, first-party web apps and partner apps — each of which arrived with its own kind of credential. They all hit the same API. The question was whether to force one credential type, split callers across separate endpoints, or accept the heterogeneity behind a single gate. (This ADR is authentication — who a caller is; authorization is ADR 0023, and tenant selection is ADR 0020.)
Decision
Recognize several credential types through one ordered authentication pipeline that resolves a single Principal, and keep tenant selection a separate, header-driven concern.
- Credential types, one gate. The pipeline resolves, in precedence order: a static bot access key → an OAuth
inka_bearer (ADR 0027) → a user session. The user path multiplexes Guardian session JWTs (rich claims incl. an "act-as-merchant" scope, revocable via a Redis blocklist) and Stripe-style API keys —sk_/pk_×live/test— with a strict environment gate (atestkey in aliveenvironment is rejected, and vice-versa). (Note: the API-key value is stored as an opaque token string in plaintext and matched by equality on lookup — it is not encrypted/hashed at rest; OAuth refresh tokens, by contrast, are hashed. Protecting the keys at rest is a gap worth closing.) - First-party login by signed assertion. A trusted first-party app that has already verified the user (passkey/TOTP) hands the API a signed
x-login-assertion(HMAC-SHA256 over{email, method, iat, jti}), which the API accepts only with a fresh timestamp (≤60s) and a single-usejti(fails closed). This replaces a legacy Redisx-trace-idhandshake that trusted whoever could write a Redis key; both are dual-accepted during the transition. - Tenant is chosen separately. Which merchant/org a request acts on is selected by header (
client-id: m-<username>/o-<username>, or a storefront subdomain), not by the credential — authentication proves who you are, tenancy (ADR 0020) picks the store.
Consequences
- Positive: one gate handles every caller class; adding a caller type is a new branch in the pipeline, not a new endpoint surface. The
sk_/pk_×live/testsplit gives clean sandbox isolation for integrators. - Positive: the signed login assertion moves trust from "who can write a Redis key" to "who holds the shared secret," with replay bounded by
iat+jti. - Neutral / to revisit: the resolver is a coupling point (every credential type meets there), and some legacy identifiers are known debt (e.g. a publicly-visible
client-keyuid the code itself flags for replacement by an API key). The login assertion is a symmetric shared secret (not JWKS), so a leak forges logins until rotated; asymmetric is deferred. - Neutral / to revisit: a transition window runs two first-party login paths (assertion + legacy Redis handshake) at once until the legacy path is removed.
Alternatives considered
- Collapse everything onto one token type: rejected — it would break existing integrations and the publishable/secret and live/test key split that integrators rely on.
- A separate endpoint per credential class: rejected — one precedence-ordered pipeline keeps the surface small; callers do not need to know which door to use.
- Keep the Redis
x-trace-idlogin handshake: rejected — it trusts anyone who can write the Redis key; a signed, single-use, time-bound assertion is strictly stronger.