# Source-Backed Connected App Contract

Status: draft for review
Date: 2026-06-14
Related: #2378, #2444

## Purpose

This document resets the design for moving Salesforce-compatible OAuth
Connected App behavior out of platform bootstrap code and into product-owned
app-pack payload.

PR #2444 correctly exposed the direction: Salesforce-compatible OAuth userinfo
and token response behavior must be product-owned, not hardcoded into the
app-blind platform route. The implementation also exposed a deeper contract
problem: a new `connected_app` pack component cannot own only a small subset of
`_System_ConnectedApp` while the OAuth runtime reads the rest of the row and L0
defaults fill the missing policy.

The target is therefore an authoritative component contract, not another
incremental seed helper.

## Decision

`connected_app` is an L0 component kind and interpreter surface. The platform
owns:

- generic storage in `_System_ConnectedApp`
- generic normalization and validation of OAuth client policy
- generic pack apply, verify, hash, and drift machinery
- generic OAuth runtime enforcement of the stored policy
- generic request-scoped product profile selection hooks

L1 app packs own product clients and product compatibility behavior. A CRM pack
may own a Salesforce-compatible connected app and may bind that app to
Salesforce-compatible userinfo/token response profiles. The pack must author
the effective OAuth client policy explicitly.

For source-backed app packs, L0 defaults may canonicalize and validate payload
values, but they must not silently define product behavior. If the OAuth
runtime reads a policy field for a pack-owned client, source-backed apply and
verify must converge that field from pack-authored data.

Field-set rule: every `_System_ConnectedApp` field selected by
`SystemRepository.GetConnectedAppByClientID` is a runtime-read field. A
source-backed component must either:

- author and verify-converge that field from pack payload, or
- make the runtime path that depends on that field unreachable through component
  validation.

Absence is not a product default. If a field is omitted because its runtime path
is unreachable, the validation rule that makes it unreachable is part of this
contract.

## Why #2444 Was Closed

The closed PR had three useful pieces:

- product-owned userinfo and token response profile seams
- request-scoped profile selection using connected app session facts
- a source-backed `connected_app` component carrying the CRM client

The component contract was too narrow. It carried only:

- `id`
- `name`
- `consumerKey`
- `consumerSecret`
- `sessionIssuerClass`

But runtime behavior depends on the broader `_System_ConnectedApp` policy:

- `ClientType`
- `RedirectUris`
- `Scopes`
- `RequirePkce`
- `TokenEndpointAuthMethod`
- `AllowedGrantTypes`
- `ConsentPolicy`
- `IssueRefreshTokens`
- `IsActive`
- `CallbackUrl` compatibility
- `ConsumerSecretHash`
- `SessionIssuerClass`
- `PermittedUsers`
- `RunAsUserId` for `client_credentials`
- `ConsentVersion`

That split violates the repository rule: one fact, one owner, one source of
truth, one read path. The pack appeared to own the connected app while L0
defaulting still authored security-critical behavior.

## Component Payload Contract

The next implementation should define one canonical source-backed
`connected_app` payload shape. Field names below are the pack vocabulary; apply
maps them to `_System_ConnectedApp`.

Required for every source-backed connected app:

- `id`
- `name`
- `consumerKey`
- `callbackUrl`
- `clientType`
- `redirectUris`
- `scopes`
- `isActive`
- `tokenEndpointAuthMethod`
- `allowedGrantTypes`
- `consentPolicy`
- `consentVersion`
- `requirePkce`
- `issueRefreshTokens`
- `permittedUsers`

Required for confidential clients:

- `consumerSecret`

Forbidden for public clients:

- `consumerSecret`
- `sessionIssuerClass`

Required for public clients:

- every declared scope must be backed by an active `oauth_custom_scope` component
  owned by the same pack. Runtime authorization and public-client CORS must read
  the same owner binding, not a process-wide/global scope-name allowlist.

Forbidden in the first source-backed component version:

- `client_credentials` in `allowedGrantTypes`
- `permittedUsers: AdminApproved`
- `runAsUserId`

Those fields remain legitimate platform concepts, but source-backed ownership
of them requires a later component contract for run-as and
`_System_ConnectedAppAccess` grants. Until then the component must not create a
runtime path that depends on those deferred facts.

Optional:

- `publisherName`
- `supportUrl`
- `sessionIssuerClass`
- `dailyApiLimit`
- `hourlyApiLimit`

Deferred from source-backed ownership until a separate contract admits them:

- `RunAsUserId`
- `PermittedUsers: AdminApproved`
- access grants in `_System_ConnectedAppAccess`
- refresh-token family policy beyond `IssueRefreshTokens = false`
- dynamic operator-managed secret rotation

If the implementation chooses not to include one of the required fields above,
it must narrow the component contract and prove that OAuth runtime behavior does
not depend on the omitted field for pack-owned clients.

`ConsentVersion` is required and verify-converged even though the consent engine
does not yet use it as a decision input. It is selected with the runtime client
row and participates in session revocation semantics, so the pack must make the
current inert value explicit rather than inheriting a database default.

## Required L0 Changes

The replacement implementation is not only a new app-pack payload. It must add
or adjust these L0 mechanisms first:

- Register `connected_app` in the source-backed component registry and
  component-kind contract table before any CRM payload uses it.
- Add a source-backed connected-app applier/upserter that persists the full
  owned policy surface. Do not reuse a narrow helper that only writes client
  identity and secret fields.
- Split current connected-app normalization into two modes:
  - admin/API mode may invent compatibility defaults for convenience.
  - source-backed mode may canonicalize and validate, but must not invent
    product policy values.
- Widen connected-app policy validation for pack apply writes so a
  `sessionIssuerClass` is accepted only when it is declared by the same owning
  pack.
- Persist `oauth_custom_scope` ownership (`OwnerPack`) and thread that owner
  through public-client scope validation and every runtime scope-policy read. A
  source-backed public client may use a scope only when the live scope row is
  owned by the same pack.
- Canonicalize source-backed `oauth_custom_scope.Name` with the same lower-case
  token representation used by `connected_app.Scopes`; business keys must match
  that canonical name so install/verify cannot accept a scope runtime will not
  resolve.
- Keep admin/API writes fail-closed for product-owned session issuer classes
  until those writes carry an explicit owning-pack context. A process-wide
  "registered class" allowlist is not sufficient.
- Reconcile the current system-context gate for `SessionIssuerClass`: system
  context alone may write `platform_browser`, but product classes require
  source-backed pack ownership.
- Add a positive accept-path test for a same-pack product session issuer class,
  not only negative owner-mismatch tests.

## Apply Contract

Pack apply must persist the full source-backed OAuth client policy in one path.
It must not call a narrow helper that only sets identity and secrets while
allowing generic create/update defaults to invent the rest of the policy.

The apply path should:

1. parse the component payload
2. canonicalize list/token fields using generic L0 normalization without
   inventing missing policy values
3. validate the full connected app policy with source-backed rules that share
   generic invariants but use the owning pack context for product session
   classes
4. verify `sessionIssuerClass` is either empty, `platform_browser`, or declared
   by the same owning pack
5. for public clients, verify every scope against an active same-pack
   `oauth_custom_scope` owner binding
6. hash `consumerSecret` before persistence
7. upsert every owned field in `_System_ConnectedApp`
8. leave no product-owned policy field to be supplied by implicit defaults

Generic defaults may still exist for admin/API creation of connected apps. They
are not the source of truth for locked source-backed pack clients.

## Verify Contract

Verify must compare the same fact surface that apply writes and the OAuth
runtime reads.

Expected projection must include:

- identity: `Id`, `Name`, `ConsumerKey`
- active state: `IsActive`
- endpoint policy: `CallbackUrl`, `RedirectUris`
- scope and grant policy: `Scopes`, `AllowedGrantTypes`, `PermittedUsers`
- client security policy: `ClientType`, `RequirePkce`,
  `TokenEndpointAuthMethod`, `ConsentPolicy`, `ConsentVersion`,
  `IssueRefreshTokens`
- product session policy: `SessionIssuerClass`
- optional metadata: `PublisherName`, `SupportUrl`, `DailyApiLimit`,
  `HourlyApiLimit`
- secret check: `ConsumerSecretHash` verified against the packaged secret, not
  printed or compared as plaintext
- public-scope owner binding: the pack-owned `oauth_custom_scope` row carries the
  same `OwnerPack` that runtime token, CORS, route/method, and resource-document
  checks use for source-backed public clients

`RunAsUserId` is not in the first verify projection because the first
source-backed contract forbids `client_credentials`. The PR that admits
`client_credentials` must move `RunAsUserId` into the owned payload and verify
projection in the same change.

Verify must fail if any owned field drifts, including fields normalized at
apply time. Reapply and verify must converge the same facts.

## Runtime Read Path

There is one runtime read path for OAuth client policy:

`_System_ConnectedApp` -> `SystemRepository.GetConnectedAppByClientID` ->
OAuth authorize/token/userinfo handlers.

Product-specific response profiles may run only after request-scoped selection.
The accepted selector inputs are facts derived from the authenticated session or
connected app, not host-global pack presence alone.

Accepted shape:

- L0 emits the generic token or userinfo response.
- L0 builds a request context containing `ConnectedAppID` and
  `SessionIssuerClass`.
- L1 product profiles run only when their selector matches that request
  context.
- A CRM Salesforce-compatible profile may add or overwrite Salesforce-shaped
  claims only for a CRM-owned Salesforce-compatible connected app session.

Rejected shape:

- installing the CRM pack globally changes every `/services/oauth2/userinfo`
  response on a coexistence host.
- a non-CRM pack can claim `salesforce_compat` and inherit CRM behavior.
- duplicate product profiles collide with last-writer-wins semantics.

## Ownership And Manageability

The `connected_app` component identity is `(pack name, kind, businessKey)`.
For locked source-backed components:

- `businessKey` must equal payload `id`
- only the owning pack may declare product-owned `sessionIssuerClass` values
- verify treats every declared policy field as pack-owned
- admin/operator mutation of locked fields is drift

Uninstall behavior remains separate roadmap work. Until uninstall is designed,
this contract only admits install, reapply, verify, and drift detection.

## PR Sequence

The replacement work should be staged so the seam is reviewable before CRM
depends on it:

1. L0 seam PR: source-backed component registry entry, component-kind contract,
   full-policy apply/verify, source-backed no-invention canonicalization mode,
   pack-aware session issuer validation, and synthetic non-CRM test pack
   coverage.
2. CRM supply PR: add the CRM `connected_app` payload to app-packs only after
   the L0 seam PR can validate it.
3. CRM compatibility PR: move Salesforce-compatible userinfo/token profiles
   behind the CRM-owned connected app and prove the end-to-end replacement.

The current L0 Salesforce-compatible userinfo/token behavior must not be
removed until an end-to-end setup -> pack apply -> login/token ->
`/services/oauth2/userinfo` test proves the replacement profile fires for the
CRM connected app.

## Acceptance Tests For The Next PR

The replacement PR should be draft until these tests exist:

- source validation rejects a `connected_app` payload missing any required
  runtime-read policy field
- source validation rejects a public connected app with a secret or
  `sessionIssuerClass`
- source validation rejects a confidential connected app without a secret
- source validation rejects `client_credentials`, `runAsUserId`, and
  `permittedUsers: AdminApproved` in the first component version
- source validation rejects a product session issuer class not owned by the
  same pack
- source validation accepts a product session issuer class declared by the same
  owning pack
- apply writes the full connected app policy, not only identity and secret
  fields
- apply does not invent source-backed policy defaults that were missing from
  payload
- admin/API writes still reject product-owned session issuer classes without an
  owning-pack context
- verify detects drift in `CallbackUrl`, `RedirectUris`, `Scopes`,
  `AllowedGrantTypes`, `PermittedUsers`, `TokenEndpointAuthMethod`,
  `ConsentPolicy`, `ConsentVersion`, `RequirePkce`, `IsActive`,
  `SessionIssuerClass`, `PublisherName`, `SupportUrl`, `DailyApiLimit`, and
  `HourlyApiLimit`
- verify checks the stored `ConsumerSecretHash` against the packaged secret
- CRM userinfo/token profiles apply only to a request whose session was issued
  by the CRM-owned Salesforce-compatible connected app
- coexistence tests prove a non-CRM connected app session remains app-neutral
  when CRM is installed on the same host
- clean L0 setup contains no CRM connected app row
- the CRM profile replacement is not allowed to remove existing L0
  Salesforce-compatible response fields until an end-to-end replacement test
  proves parity for a CRM connected app session

## Residual Work

This contract does not finish the whole #2378 roadmap. It only fixes the
connected app seam needed before product-owned OAuth compatibility responses can
land safely.

Still outside this draft:

- full uninstall/deactivation semantics for app-pack connected apps
- operator-managed secret rotation for source-backed clients
- broad Connected App access grants as pack components
- frontend administration for product-owned connected apps
- final distribution-owned dependency model
