# Contract: Inbound SaaS Request Authentication ## Request Every inbound request from an integrated SaaS product carries: - **Header**: `Authorization: Bearer ` — the signed short-lived token from research.md ("Signed short-lived token format"). - **Body**: JSON matching the Inbound Request Contract shape in `data-model.md`, validated with a `.strict()` Zod schema. ## Validation order (fixed — each step's failure short-circuits the rest) Request body shape is checked first because it's cheap and stateless — no reason to spend a crypto verification or a database lookup on a request that's malformed anyway: 1. **Request body matches the strict schema** (no unknown fields) → else `400 VALIDATION_ERROR`. 2. **`Authorization: Bearer ` header present and well-formed** → else `401 INVALID_INTEGRATION_CREDENTIAL`. 3. **`ProductIntegration` exists for the body's `productId`** → else `401 INVALID_INTEGRATION_CREDENTIAL` (identical to step 4's failure — see FR-010). 4. **Token verifies** against that integration's `credentialRef` or non-expired `previousCredentialRef` → else `401 INVALID_INTEGRATION_CREDENTIAL`. 5. **Token not expired** (beyond the configured clock-skew tolerance) → else `401 INVALID_INTEGRATION_CREDENTIAL`. 6. **Token `jti` not previously seen** (replay check against Redis) → else `401 INVALID_INTEGRATION_CREDENTIAL`. 7. **`ProductIntegration.revokedAt IS NULL`** → else `401 INVALID_INTEGRATION_CREDENTIAL`. 8. **`ProductIntegration.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`. 9. **`Product.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`. 10. **`tenantId`/`userId` fall within `ProductIntegration.allowedScope`** → else `403 REQUEST_OUT_OF_SCOPE`. 11. **Rate limit (integration-level, then user-level) not exceeded** → else `429 RATE_LIMIT_EXCEEDED` (User Story 3 — applied after auth succeeds, on the resolved integration/user identity). Only after all eleven checks pass does `request.reqContext` get populated (`productId` → internal `Product.id`, `customerId` → `CustomerReference.id`, `tenantId` → `externalTenantId`, `actorType` → `CUSTOMER`, `actorId` → `externalUserId`) and the request reaches its route handler. Every attempt — pass or fail at any step — writes one `AuditLog` row (data-model.md). ## Guarantees (callable contract) 1. **No side effect before full validation.** No `CustomerReference` row, no `AuditLog` success row, no downstream processing happens until step 9 passes. 2. **Identical response for "unregistered" and "invalid credential."** Per research.md's FR-010 decision — callers cannot distinguish "you don't exist" from "you exist but this credential is wrong." 3. **Distinguishable suspension and scope errors.** `403 PRODUCT_INTEGRATION_SUSPENDED` and `403 REQUEST_OUT_OF_SCOPE` are each their own error code, safe to distinguish per research.md. 4. **No raw credential value ever appears in a log, audit row, or error response.** 5. **A revoked credential is rejected starting with the very next request** — no propagation delay (SC-002). 6. **During a rotation's transition window, both the old and new credential validate successfully** (SC-003). 7. **An unknown field anywhere in the request body rejects the entire request**, not just that field (FR-008). ## Admin: Integration Lifecycle Endpoints Extends the existing `catalog/products` module (`src/modules/catalog/products/`). Registration is keyed by the product's *external* id (the product may not exist locally yet — registering an integration creates it); every other operation is keyed by the `ProductIntegration`'s own id, since that's what registration returns and what admin tooling references thereafter: | Route | Operation | Effect | |---|---|---| | `POST /admin/products/:externalProductId/integration` | Register integration | Finds-or-creates the `Product`, then creates its `ProductIntegration` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and the request body's `allowedScope` | | `POST /admin/integrations/:integrationId/rotate` | Rotate credential | Moves current `credentialRef` → `previousCredentialRef`, sets `previousCredentialExpiresAt` (research.md's rotation transition window), issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again — matches "never persist raw credential," see research.md "Credential storage") | | `POST /admin/integrations/:integrationId/revoke` | Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately | | `PATCH /admin/integrations/:integrationId/status` | Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` | | `GET /admin/integrations/:integrationId/audit-trail` | Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first | All five require an admin-authenticated caller via the existing human/admin JWT plugin (`fastify.authenticate`, `src/plugins/auth.plugin.ts`) — a separate concern from the product-integration signed-token auth this contract otherwise describes. **Known limitation**: `auth.plugin.ts`'s `authenticate` decorator is currently a stub that performs no real JWT verification (it exists as scaffolding — see `src/modules/identity/auth`, itself unimplemented). These admin endpoints are therefore not actually access-controlled yet; real JWT verification is a separate, pre-existing gap this feature surfaces but does not fix.