# PRD - OAuth Authorization Code + PKCE for External Apps

Status: Draft implementation plan
Date: 2026-06-01
Related issue: #1702
Source design: `docs/design/oauth-pkce-external-apps.md` from commit
`38c8b25d` / sibling worktree `oauth-pkce-design-doc`.
Layout note: frontend path references in this PRD are implementation anchors
from the document's review window. Verify current frontend source ownership
against `frontend-l0-l1-ownership-inventory.md` before using them as paths on
`main`.

> **MCP ownership update (2026-09-08):** [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md)
> owns MCP protocol, tools, credentials and onboarding. MCP is an independent
> L1 Worker using Runtime user permissions; it does not require Connected App,
> an MCP-specific scope or a resource profile. This document governs the
> broader **non-MCP** OAuth/PKCE programme and adds no MCP prerequisites.

## 0. Bottom Line

NexusCRM should not ship "PKCE support" as a parameter-only patch. The product
needs a complete public-client authorization surface: public Connected Apps,
S256 PKCE, explicit scopes, consent or trusted/admin-preapproved policy,
revocation, audit, and a narrow discovery contract.

The first shippable release is intentionally small:

1. Public clients can use Authorization Code + S256 PKCE with no client secret.
2. Public-client scopes are explicit metadata-backed policies; the
   Salesforce-compatible CRM pack is expected to own the first read-only scope.
3. User consent or explicit trusted/admin-preapproved policy is required before
   a code is issued.
4. Authorization codes are one-time, short-lived, hashed at rest, and bound to
   client, redirect URI, user, scope, issuer, consent, and PKCE challenge.
5. Access tokens are NexusCRM sessions bound to user, Connected App, grant type,
   and non-empty scope.
6. Existing password and client-credentials grants remain compatibility
   behavior and are not advertised as part of this public-client program.
7. Discovery metadata is an allowlist of completed standards contracts, not a
   reflection of every route that exists.

Browser-hosted third-party apps are supported in P1 only when they avoid
cross-origin direct calls, for example through a same-origin backend/proxy or a
same-origin hosted tool. PKCE does not make browser CORS safe, and P1 does not
add per-Connected-App CORS.

No general third-party public app may issue delegated-user tokens until PKCE,
consent, explicit public scopes, route enforcement, revocation, admin/user
revocation UX, audit, and discovery gates all pass. Until then, public-client
behavior stays behind a feature flag or equivalent internal-only rollout guard.

## 0A. Current Draft PR Slice

PR #1735 is a guarded substrate slice, not the public launch of external OAuth.
The PRD remains the target architecture and launch gate; this slice only lands
the pieces that can be made safe behind `NEXUSCRM_PUBLIC_OAUTH_PKCE_ENABLED`.

This slice implements:

- public/confidential Connected App policy fields and validation
- no-secret public clients
- S256 PKCE code issuance/exchange for already-authenticated trusted or
  admin-preapproved public apps when a metadata scope policy is present
- hashed authorization codes
- generic scope-registry method, route-template, and resource-link policy
  fields, with public issuance failing closed until metadata supplies a policy
- canonical public issuer validation through `API_BASE_URL`
- new secret writes hashed at rest, with plaintext cleared from storage and
  generic reads hard-blocked

This slice deliberately does not launch:

- prompt-consent public apps
- unauthenticated browser authorize resume after `/login`
- user/admin consent inventory and revocation UX
- server-owned confidential secret generation/rotation with one-time display
- discovery metadata
- refresh tokens, OIDC, introspection, revocation, DCR, CORS, DPoP, or mTLS

Until the server-owned authorize-request snapshot exists, unauthenticated public
authorize requests fail closed instead of redirecting to `/login` and losing the
OAuth tuple. That preserves the PRD's fail-closed direction while leaving the
full login/consent resume flow to Phase 4/5. Existing confidential compatibility
may keep its legacy login behavior.

## 1. Problem

External dashboards, browser apps, local tools, CLIs, MCP/Claude-style clients,
and agent-built apps need delegated access to NexusCRM APIs. Those clients
usually cannot protect a client secret and should never receive a user's
password, a static admin token, or a broad first-party session.

Current NexusCRM has OAuth-shaped pieces, but the implementation is
confidential-client and bootstrap-flow shaped:

| Surface | Current state | Public-client risk |
| --- | --- | --- |
| `/services/oauth2/authorize` | Issues authorization codes after validating one `CallbackUrl`. | No PKCE, no consent, no public-client policy, and no preserved request snapshot through login/consent. |
| `/services/oauth2/token` | Supports `authorization_code`, `password`, and `client_credentials`. Authorization-code exchange requires `client_secret`. | Public clients cannot redeem safely; legacy grants could be over-advertised if discovery mirrors routes. |
| `_System_OAuthCode` | Stores plaintext code, client, redirect URI, scope, user, nonce, expiry, and used flag. Consume uses a transaction and `FOR UPDATE`. | One-time consume is good, but plaintext code and missing PKCE/context binding are not enough. |
| `_System_ConnectedApp` | Stores required plaintext `ConsumerSecret`, one callback URL, scopes, active state, run-as user, permitted users, and API limits. Current setup UI can write these rows through generic data APIs. | Public apps need no secret; confidential secrets need server generation, hashing, and one-time display. Policy writes need one authoritative validation/revocation/audit path, not ad hoc generic updates. |
| Scope enforcement | Empty scope means full access. `api`/`full` are broad; `api.read` grants all GET/HEAD routes. | Public clients need fail-closed, explicit, non-empty scopes and a narrower route inventory than current `api.read`. |
| Session validation | Re-checks session revocation, user active state, Connected App active state, and DB-backed scope/app id. | Good base, but public app consent revocation and user/app tuple revocation are missing. |
| Connected App mutation hooks | Existing record persistence revokes sessions when app config or access rows change. | Good base, but P1 must define exact revocation semantics for consent and delegated sessions. |
| Admin UI | Existing Connected App pages manage current fields and access policy. | Admins cannot create a public no-secret app, register multiple redirects, manage consent policy, or see/revoke user consent. |
| `/userinfo` and `/introspect` | Routes exist. | Advertising them would imply OIDC/RFC 7662 contracts the current implementation does not satisfy. |

The product risk is not only token theft. The deeper risk is accidentally
turning transitional compatibility handlers into a public standards contract.

## 2. Product Goals

1. Let a user authorize an external public client without sharing a password or
   client secret.
2. Preserve Salesforce-compatible token response shape where possible:
   `access_token`, `token_type`, `expires_in`, `scope`, `instance_url`, `id`,
   and `issued_at`.
3. Let admins distinguish public delegated-user apps from confidential
   server-side apps.
4. Require explicit non-empty allowed scopes for public apps.
5. Keep delegated API access subject to app policy, consent, OAuth scope,
   `ApiEnabled`, object permissions, field permissions, RLS/sharing, rate
   limits, user active state, app active state, and session revocation.
6. Publish authorization server metadata only after the implemented contract is
   complete and tested.
7. Let the separate MCP design adopt this programme's generic PKCE foundation
   where it explicitly does so; this PRD defines no MCP client contract.
8. Keep L0 platform code metadata-driven and multi-app neutral.

## 3. Non-Goals For The First Release

- Full OpenID Connect provider behavior.
- Advertising `openid`, `userinfo_endpoint`, `jwks_uri`, or OIDC discovery.
- Advertising the current `/services/oauth2/introspect` helper.
- Dynamic Client Registration or Client ID Metadata Documents.
- **Non-MCP** resource indicators, protected-resource metadata, or
  audience-bound resource enforcement. The MCP contract is owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
- Cross-origin browser CORS for direct API calls.
- Refresh tokens for public clients.
- Sender-constrained tokens through DPoP or mTLS.
- Password-auth retirement.
- Sysadmin login-as.
- Rewriting Salesforce-compatible CRUD/query/FLS/RLS/sharing behavior.
- Tenant-specific OAuth policy in this public repo.

## 4. NexusCRM Architecture Requirements

This program must follow the strong NexusCRM design reading:

| Principle | Requirement |
| --- | --- |
| L0 is generic substrate | OAuth code storage, PKCE verification, consent engine, scope registry mechanics, discovery builder, and revocation hooks are platform mechanisms. They must not branch on CRM objects, profile literals, tenant fields, or tenant tables. |
| L1 owns app-pack policy | `crm.data.read` and its CRM route/link inventory are Salesforce-compatible CRM pack policy once the scope model can constrain generic data routes by owning app pack. Platform runtime must not ship that CRM scope name or route/link inventory as a code allowlist; absent metadata, public token issuance fails closed. |
| One fact, one read path | Scope authorization and resource-document link filtering must read the same registry. Consent reuse must read one consent predicate. Redirect URI validation must read one canonical redirect list. |
| One mutation authority | Connected App, Connected App Access, consent, and OAuth credential writes must pass through one policy service or generic CRUD hook that validates invariants, audits changes, and revokes affected sessions. If a write path cannot call that authority, it must be blocked for these tables. |
| Public-core boundary | No tenant-specific `__c` fields, tenant app names, customer names, or tenant overlay payloads. |
| Salesforce v63 boundary | Any changes under `backend/internal/interfaces/rest/` must follow `docs/design/salesforce-v63-api-contract.md` before implementation. Token and error shapes must stay deliberate Salesforce-compatible deltas. |
| Fail closed | Empty public scopes, missing PKCE, unsupported methods, unknown redirects, revoked consent, and mixed issuer/base URL state must block public token issuance. |

## 5. Users And Stories

1. As an end user, I can approve a trusted external dashboard and give it
   read-only API access without typing my password into that dashboard.
2. As an end user, I can deny consent and return to the external app with a safe
   OAuth error.
3. As an end user, I can revoke a previously approved app and know existing
   delegated sessions stop working immediately.
4. As an admin, I can create a public Connected App without a secret, with S256
   PKCE required and a specific redirect URI list.
5. As an admin, I can mark an app trusted/admin-preapproved only through an
   explicit policy field, not by naming convention or localhost redirect.
6. As an admin, I can deactivate an app or remove app access and immediately
   invalidate affected delegated sessions.
7. As a confidential integration owner, my existing secret-based clients keep
   working unless a later PR explicitly migrates them.
8. As a standards-based client, I can discover only the completed
   authorization-code + PKCE public-client contract.
9. As a security reviewer, I can trace every public token to user, app, scope,
   consent, grant type, and audit events without seeing secrets in logs.
10. As a platform maintainer, I can add a future Jira-like app public scope
    through metadata/pack policy rather than editing OAuth route branches.

## 6. Functional Requirements

### R0. Authoritative Mutation Path

Connected App policy is security state. The implementation must define one
authoritative mutation authority for:

- `_System_ConnectedApp`
- `_System_ConnectedAppAccess`
- `_System_OAuthConsent`
- future OAuth credential/refresh-token tables

That authority can be a dedicated service called by admin REST endpoints and by
generic record persistence hooks, but it must be the single place that:

- validates client type, redirect, scope, grant, consent, and secret invariants
- emits setup/security audit events
- revokes affected live sessions
- invalidates app-active/session caches when needed
- redacts or withholds secret fields from normal reads

Generic data API writes, bulk updates, pack/apply paths, setup pages, and
future admin endpoints must either call this same authority or be rejected for
these security tables. Direct writes that can bypass revocation or audit are
not acceptable for public-client launch.

### R1. Connected App Client Type

Add explicit client-policy fields to `_System_ConnectedApp`:

| Field | P1 behavior |
| --- | --- |
| `ClientType` | `public` or `confidential`; existing rows migrate as `confidential`. |
| `RequirePkce` | Required true for public apps. Confidential apps may opt in. |
| `TokenEndpointAuthMethod` | `none` for public; `client_secret_post` for existing confidential behavior. `client_secret_basic` is future work. |
| `AllowedGrantTypes` | Public apps start with `authorization_code` only. Existing confidential/bootstrap rows may retain current transition grants. |
| `RedirectUris` | Authoritative registered redirect list when populated. Backfill from legacy `CallbackUrl`. |
| `ConsentPolicy` | `prompt`, `trusted`, or `admin_preapproved`. |
| `ConsentVersion` | Bump to force reprompt after material policy/copy changes. |
| `IssueRefreshTokens` | False in P1. |
| `PublisherName` / `SupportUrl` | Displayed to users/admins for app identity. |

Validation:

- Public apps cannot have `TokenEndpointAuthMethod != none`.
- Public apps cannot have empty `Scopes`.
- Public app scopes must be a subset of the public P1 allowlist.
- Public apps cannot use `password`, `client_credentials`, or future
  refresh-token grants.
- Existing `CallbackUrl` remains compatibility input until migration completes,
  but `RedirectUris` wins when both exist.

### R1A. Connected App Secret Lifecycle

Public apps do not have a client secret. The current required plaintext
`ConsumerSecret` shape must be replaced before public apps ship:

- make the legacy secret field nullable or move credentials to a dedicated
  credential table
- store only a secret hash for confidential clients
- generate confidential client secrets server-side with cryptographic randomness
- display a new confidential secret exactly once at creation/rotation
- never return secret material through list/detail/query APIs
- rotate confidential secrets through an audited action
- keep existing confidential clients working through a deliberate migration
  path

Token response `signature` behavior must also be explicit:

- public authorization-code token responses omit `signature`
- confidential compatibility responses may keep the existing Salesforce-style
  signature only while a client secret exists and the flow authenticates with
  that secret
- no implementation may invent a fake public-app secret just to populate
  `signature`

### R2. Public Scope Contract

Implementation note from the June 2, 2026 review loop: the current route-only
scope registry cannot yet constrain generic `/services/data` object/query routes
to only CRM-pack objects. To keep P1 greenfield usable without turning
`crm.data.read` into an overbroad CRM-named scope, the current implementation
ships the first metadata-backed public scope as the source-backed
`external.records.read` CRM pack component.
`crm.data.read` remains the CRM-pack target after object-pack constraints are
added to the policy model.

The current P1 greenfield public-client scope is:

```text
external.records.read
```

The expected first CRM-pack public-client scope after object-pack constraints
exist is:

```text
crm.data.read
```

Public apps must not be configurable with `api`, `full`, `api.read`,
`chatter_api`, or `openid`. A public authorize request must include a non-empty
`scope` and every requested scope must be both:

1. configured on the Connected App, and
2. explicitly allowed by the metadata-backed public-client scope registry.

An empty public-app scope, empty authorization-code scope, or empty public
session scope is invalid. It must never inherit the current compatibility
meaning of "full API access".

### R3. Metadata-Driven Scope Registry

This PRD owns the **non-MCP** public-app scope allowlist, route/link inventory,
and product semantics. The generic source-backed scope registry remains a
non-MCP OAuth primitive. It must not introduce MCP scopes, registration or
resource profiles; MCP uses existing Runtime user identity under the [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
The generic registry must support:

| Registry fact | Meaning |
| --- | --- |
| `ScopeName` | Public OAuth scope string. |
| `ClientTypes` | Client types that may request it. |
| `Methods` | Allowed HTTP methods. |
| `RoutePatterns` | Exact route patterns, using router route templates rather than substring matching. |
| `ResponseLinkPolicy` | Resource-document links allowed for this scope. |
| `OwnerPack` / `OwnerVersion` | App pack that owns the policy. |

Current P1 greenfield policy maps `external.records.read` to these read-only
Salesforce-compatible routes. The future CRM-pack `crm.data.read` policy can
reuse this route shape only after object-pack constraints prevent it from
covering non-CRM app objects.

- `GET/HEAD /services/data`
- `GET/HEAD /services/data/:version`
- `GET/HEAD /services/data/:version/sobjects`
- `GET/HEAD /services/data/:version/sobjects/:sobject/describe`
- `GET/HEAD /services/data/:version/sobjects/:sobject/:id`
- `GET/HEAD /services/data/:version/query`
- `GET/HEAD /services/data/:version/queryAll`
- `GET/HEAD /services/data/:version/query/:locator`
- `GET/HEAD /id/:orgId/:userId`
- `GET/HEAD /services/data/:version/id/:orgId/:userId`

P1 explicitly excludes:

- `/services/oauth2/userinfo`
- `/ws`
- `/cometd/*`
- `/connect/*`
- `/tooling/*`
- `/ui-api/*`
- `/analytics/*`
- `/metadata/*`
- `/composite/*`
- file download/write routes
- all mutating methods
- any future route until mapped and tested

The same registry must filter resource-document links. For an
`external.records.read` public session, `/services/data/:version` may advertise
only covered links such as `sobjects`, `query`, `queryAll`, and `identity`.

Do not add `case "external.records.read"` or `case "crm.data.read"` to generic
L0 scope middleware as a permanent branch. P1 must not carry route inventory in
platform code; if scope metadata is not ready, public OAuth issuance remains
disabled for that scope.

### R4. Authorization Endpoint

`GET /services/oauth2/authorize` supports public-client requests:

```text
response_type=code
client_id=<consumer key>
redirect_uri=<registered uri>
scope=external.records.read
state=<client csrf value>
code_challenge=<base64url sha256 challenge>
code_challenge_method=S256
```

Behavior:

1. Load an active Connected App by `client_id`.
2. Validate `response_type=code`.
3. Validate `redirect_uri` against `RedirectUris` using exact match in P1.
4. Validate public app configuration and requested scope before any code is
   generated.
5. Require `code_challenge_method=S256` and validate `code_challenge` grammar
   for public or PKCE-required apps.
6. If unauthenticated, send the user through login while preserving the full
   authorization request in a server-side snapshot.
7. Bind the preserved request to the authenticated user session before consent.
8. Check Connected App access policy.
9. Show consent unless the app is trusted/admin-preapproved or an existing
   consent satisfies the reuse predicate.
10. On approval, generate a high-entropy one-time code.
11. Store code hash and request snapshot: app, client type, redirect URI, user,
   scope, issuer, consent id, PKCE challenge, method, auth time, expiry, and
   `Used=false`.
12. Redirect to the trusted registered redirect URI with `code` and original
   `state`.

Do not redirect to unknown or invalid redirect URIs. Return JSON or an internal
error page for those cases.

Authorize request snapshots must be:

- keyed by an unguessable server-generated id
- TTL-bound, with a maximum lifetime no longer than the authorization-code TTL
- bound to the browser session before consent approval
- single-use after approve or deny
- protected from cross-tab confusion by matching client id, redirect URI,
  scope, state, and PKCE challenge
- scrubbed of secrets and never logged in full

Redirect URI validation must reject:

- fragments
- wildcard hosts or paths
- embedded credentials/userinfo
- non-HTTPS hosted redirects
- scheme-relative or relative URLs
- unsupported schemes
- localhost broadening unless a future native/CLI policy explicitly opts in

The P1 exception is exact-match loopback HTTP redirects such as
`http://127.0.0.1:<registered-port>/callback` when the exact URI is registered.
Do not allow arbitrary localhost port variance in P1.

### R5. Consent

Add `_System_OAuthConsent`:

| Field | Notes |
| --- | --- |
| `UserId` | Approving user. |
| `ConnectedAppId` | App receiving delegated access. |
| `Scope` | Canonical normalized scope set. |
| `ApprovedAt` | First approval timestamp. |
| `LastUsedAt` | Updated on successful token exchange and future refresh. |
| `RevokedAt` | Null when active. |
| `RevokedBy` | User/admin/system actor. |
| `ConsentVersion` | Must match current Connected App version to reuse. |

Consent reuse is allowed only when all are true:

- same user
- same Connected App
- `RevokedAt` is null
- `ConsentVersion` equals the current Connected App `ConsentVersion`
- requested scope set is a subset of stored consent scope set

Scope expansion, app mismatch, user mismatch, revoked consent, or version bump
forces a new prompt. Consent approve/deny POST must use NexusCRM CSRF
protection and a server-owned request snapshot.

### R6. Token Endpoint

`POST /services/oauth2/token` supports public authorization-code exchange:

```text
grant_type=authorization_code
client_id=<consumer key>
code=<one-time code>
redirect_uri=<same registered uri>
code_verifier=<original verifier>
```

Behavior:

1. Accept `application/x-www-form-urlencoded`; keep JSON as existing
   compatibility behavior only.
2. Load active Connected App by `client_id`.
3. Authenticate client according to `TokenEndpointAuthMethod`.
4. For public clients, reject supplied `client_secret`.
5. Consume the authorization code atomically by code hash.
6. Validate code expiry, one-time use, client id, redirect URI, issuer, client
   type snapshot, and scope.
7. Validate `code_verifier` grammar and S256 challenge match.
8. Re-check app active state, redirect policy, allowed grant type, allowed
   scopes, user active/locked state, app access policy, and consent.
9. Reject empty public code-bound scope or session scope.
10. Create a session with `ConnectedAppId`, non-empty `Scope`, and
    `GrantType=authorization_code`.
11. Return Salesforce-compatible token response with no-store headers.

Do not return `refresh_token` in P1.

### R6A. OAuth Error Contract

OAuth responses must not expose internal error strings. Implement a typed error
mapper at the REST boundary and log/audit the detailed reason separately.

| Case | HTTP status | OAuth error | Safe client description | Redirect? |
| --- | --- | --- | --- | --- |
| Unknown client id | 400 | `invalid_client` | `invalid client` | No |
| Inactive client | 400 or 403 | `invalid_client` | `invalid client` | No |
| Invalid or unregistered redirect URI | 400 | `invalid_request` | `invalid redirect_uri` | No |
| Unsupported response type | 302/400 | `unsupported_response_type` | `response_type must be code` | Only to a trusted registered redirect |
| Missing/malformed PKCE | 302/400 | `invalid_request` | `invalid PKCE challenge` | Only to a trusted registered redirect |
| Missing/unsupported public scope | 302/400 | `invalid_scope` | `invalid scope` | Only to a trusted registered redirect |
| App access denied | 302/403 | `access_denied` | `user is not authorized for this app` | Only to a trusted registered redirect |
| Consent denied | 302 | `access_denied` | `user denied consent` | Yes, trusted redirect only |
| Invalid, expired, reused, or mismatched code | 400 | `invalid_grant` | `invalid authorization code` | Not applicable |
| Wrong or malformed verifier | 400 | `invalid_grant` | `invalid authorization code` | Not applicable |
| Public client sends secret | 400 | `invalid_request` | `client_secret is not allowed for this client` | Not applicable |
| Confidential client missing/wrong secret | 400 | `invalid_client` | `invalid client` | Not applicable |
| Server/config failure | 500 | `server_error` | `authorization server error` | No untrusted redirect |

The audit/log reason may distinguish `wrong_verifier`, `code_reuse`,
`redirect_mismatch`, `consent_revoked`, `app_inactive`, and similar categories.
The client-facing `error_description` must stay sanitized.

### R7. Canonical External Base URL

Add one configured canonical external base URL for public OAuth surfaces. This
single value must produce:

- discovery `issuer`
- discovery endpoint URLs
- token response `instance_url`
- token response `id`
- `/services/data/:version` resource links
- `/services/data/:version` `identity` link

Public OAuth must fail closed if the value is unset, malformed, mixed-origin, or
inconsistent across those call sites. Do not derive public issuer or identity
URLs from request `Host`, `X-Forwarded-Host`, or parallel helpers.

### R8. Revocation

MVP user/admin app revocation is in scope even though RFC 7009 discovery is not.

Required revocation behavior:

- User consent revocation immediately revokes live
  `authorization_code` sessions for the same `(user, ConnectedApp)` tuple.
- Admin app deactivation revokes or invalidates all app-bound sessions.
- Removing app access grants revokes affected delegated sessions. A coarse
  app-wide revoke is acceptable for P1 if targeted profile/permission-set
  invalidation is not ready, but it must be explicit.
- Changing `PermittedUsers`, `RunAsUserId`, active state, app scopes, client
  type, token auth method, redirect URIs, or consent policy revokes affected
  app sessions.
- Revocation writes audit events with actor, target user/app, source, reason,
  and affected session count.
- Cache invalidation is part of revocation. If app-active or user-active state
  is cached, mutation paths must revoke sessions and invalidate caches
  synchronously enough that "immediate" invalidation is true from the caller's
  next API request.

The existing `/services/oauth2/revoke` can remain, but do not advertise
`revocation_endpoint` until the standards contract is hardened.

### R9. Discovery Metadata

Add `/.well-known/oauth-authorization-server` only after the public-client
contract above is complete and verified.

MVP response:

```json
{
  "issuer": "https://nexus.example.com",
  "authorization_endpoint": "https://nexus.example.com/services/oauth2/authorize",
  "token_endpoint": "https://nexus.example.com/services/oauth2/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post"]
}
```

MVP metadata must not include:

- `password`
- `client_credentials`
- `refresh_token`
- `offline_access`
- `openid`
- `userinfo_endpoint`
- `introspection_endpoint`
- `registration_endpoint`
- `jwks_uri`
- `scopes_supported`
- non-MCP protected-resource metadata

### R10. Admin And User UX

Admin and user UX are part of the first public launch gate, not a later polish
phase. Admin UX must support:

- Create/edit public vs confidential apps.
- Public apps without a secret.
- Redirect URI list editing.
- Explicit scope selection from the allowed public scope list.
- Consent policy, publisher name, and support URL.
- Warnings for broad scopes on confidential apps and for unverified publishers.
- App usage summary: active sessions, last token issue, last consent use.
- Deactivate app and revoke sessions.

User UX must support:

- Consent screen showing app name, publisher/support, requested scopes, and
  whether the app is trusted/admin-preapproved.
- Approve and deny.
- User-visible consent inventory and revoke action.

## 7. Security Requirements And Edge Cases

| Risk / edge case | Required control | Required proof |
| --- | --- | --- |
| Generic CRUD bypasses security hooks | Connected App/consent writes route through one mutation authority or are rejected. | Generic API, admin UI, bulk update, and pack/apply mutation tests prove audit and revocation happen. |
| Public app forced to have a secret | Secret field is nullable or split into credential table; public apps store no secret and omit `signature`. | Public app create/read tests show no secret material and no token signature. |
| Confidential secret leakage | Server-generated hash-at-rest secrets with one-time display and redacted reads. | List/detail/query APIs never return secret material; rotation audit exists. |
| Authorization code interception | S256 PKCE, one-time consume, short TTL, code hash storage. | Wrong verifier and code reuse return `invalid_grant`. |
| Authorization code injection | Bind code to client, redirect URI, issuer, user, scope, consent, and PKCE challenge. | Code from another app/redirect/user/scope cannot be redeemed. |
| Plain PKCE downgrade | Support only `S256` for public clients. | `plain`, missing method, and unknown methods fail at authorize. |
| Malformed verifier/challenge | Enforce RFC 7636 grammar and length. | Verifier under 43 chars, over 128 chars, or with invalid chars fails. |
| Open redirect | Exact registered redirect matching; no error redirect to untrusted URI. | Invalid redirect test proves no redirect to attacker host. |
| Malicious redirect shape | Reject fragments, wildcards, credentials, non-HTTPS hosted URLs, relative URLs, unsupported schemes, and broad localhost. | Validation tests cover each rejected shape. |
| Host header / issuer mix-up | Configured canonical external base URL; fail closed on missing/mixed origins. | Discovery, token `id`, `instance_url`, and identity link share one origin. |
| Public app configured with empty scope | Reject config/use; empty public scope never means full access. | Empty app scope, empty request scope, and empty session scope fail. |
| Public app requests broad legacy scope | Public allowlist rejects `api`, `full`, `api.read`, `chatter_api`, `openid`, custom, and unknown scopes. | `invalid_scope` tests for each blocked scope. |
| Consent CSRF | Consent POST uses normal CSRF; request snapshot bound to user session. | Missing/wrong CSRF cannot approve consent. |
| Consent replay/stale consent | Reuse predicate includes user, app, current version, active state, and scope subset. | Scope expansion/version bump forces prompt. |
| Consent revoked after code issue | Token exchange re-checks consent before session creation. | Revoked consent blocks token exchange and live API calls. |
| App access removed after code issue | Token exchange re-checks access; mutation hooks revoke active sessions. | Access removal invalidates existing delegated sessions. |
| App deactivated after token issue | Session validation checks app active state and/or sessions are revoked. | Existing app-bound token fails after deactivation. |
| Redirect URI removed after code issue | Token exchange re-checks current redirect policy or rejects on app policy version mismatch. | Removed redirect blocks exchange. |
| Client type changed after code issue | Code stores client type snapshot; token re-checks current app policy. | Public/confidential type mismatch blocks exchange. |
| Public client sends secret | Reject rather than ignore. | Public-with-secret test fails. |
| Confidential client omits secret | Existing confidential behavior still requires secret. | Confidential-without-secret test fails. |
| Token response cached | `Cache-Control: no-store` and `Pragma: no-cache`. | Header tests pass. |
| Secrets in logs | Central redaction for token, code, verifier, password, client secret, future refresh token. | Log scrub tests or logger hook tests. |
| Token scanning via introspection | Do not advertise current introspection; future endpoint requires caller auth and rate limits. | Metadata excludes introspection. |
| OIDC client confusion | Do not advertise `openid`, `userinfo_endpoint`, `jwks_uri`, or ID token behavior. | Metadata excludes OIDC fields. |
| Browser CORS confusion | No per-app CORS in P1; no union of all app origins. | No CORS contract added; future design required. |
| Refresh token replay | No refresh tokens in P1. Future phase must rotate and revoke families on replay. | Metadata excludes `offline_access` and `refresh_token`. |
| Route substring bypass | Scope enforcement uses router route template or normalized registry, not URL substring. | SObject/path names containing `connect` or `userinfo` do not satisfy scopes. |
| Resource link over-advertisement | Resource links filtered by same scope registry as authorization. | Public read token sees only allowed links. |
| Multiple browser tabs | Server-side request snapshots are per transaction and bound to user/session/state. | Approving one request cannot approve another tab's scope/client. |
| Login-state confusion | Authorize request survives login; post-login user is the consent/token subject. | Code user id equals authenticated consenting user. |
| User deactivation/lock after code issue | Token exchange and session validation reject inactive/locked users. | Deactivation before exchange blocks issuance; after issuance blocks API. |
| Rate-limit abuse | Token and authorize endpoints rate-limit by IP and, when known, app/client id and reason. | Repeated invalid verifier/code attempts are throttled/audited. |
| Unsafe error leakage | REST boundary maps internal errors to safe OAuth errors. | Token and authorize tests prove `err.Error()` is not returned. |

## 8. Implementation Plan

### Phase 0 - Acceptance And Work Item Split

Deliverables:

- Confirm this PRD and the source design are the accepted implementation plan.
- Decide open product/security inputs:
  - public-app access-token TTL
  - exact registered localhost redirects for P1 and the separate future issue
    for loopback port variance
  - consent copy and trusted/admin-preapproved workflow
  - canonical external base URL config key and deployment validation
  - whether P1 scope registry payload can be external app-pack owned now or is
    a documented transition mapping
- Add an implementation epic checklist with the phases below.

Gate:

- Reviewers agree no public client can issue a token until P1 security gates
  pass.

### Phase 1 - Schema, Models, And Policy Metadata

Primary files:

- `backend/internal/bootstrap/system_tables.json`
- generated shared/backend schema and constants
- `shared/pkg/models/z_generated.go`
- `frontend/src/generated-schema.ts`
- generic records persistence hooks / Connected App admin API
- migration/bootstrap convergence tests

Work:

1. Add Connected App client-policy fields.
2. Replace required plaintext `ConsumerSecret` with a nullable legacy field
   plus secret-hash storage, or a dedicated credential table.
3. Add server-generated secret creation/rotation metadata for confidential
   clients.
4. Add the authoritative Connected App/consent mutation service and wire it
   into generic records persistence or block generic writes for these tables
   until equivalent hooks exist.
5. Add OAuth code PKCE/hash fields.
6. Add `_System_OAuthConsent`.
7. Add indexes/constraints for:
   - Connected App consumer key uniqueness
   - OAuth code hash lookup
   - active consent lookup by `(UserId, ConnectedAppId, ConsentVersion)`
   - active delegated sessions by `(UserId, ConnectedAppId, GrantType)`
8. Backfill existing Connected Apps as `confidential`,
   `TokenEndpointAuthMethod=client_secret_post`, and existing grant
   compatibility.
9. Backfill existing confidential secret hashes from current secret material,
   then stop returning secret material through generic reads.
10. Backfill `RedirectUris` from `CallbackUrl`.

Gate:

- Existing confidential apps preserve behavior.
- New public app rows cannot be saved with empty scopes, broad public scopes,
  unsupported grants, or a secret-based token auth method.
- Public Connected App rows can exist without any secret value.
- Confidential secret material is generated server-side, displayed once, and
  redacted from list/detail/query APIs.
- Generic, admin, bulk, and pack/apply mutations either invoke the same policy
  authority or are rejected for OAuth security tables.

### Phase 2 - Canonical OAuth URL Helper

Primary files:

- REST/system URL helpers
- OAuth token response code
- version-root and identity-link builders
- server config validation

Work:

1. Introduce one canonical external base URL config.
2. Validate scheme, host, path normalization, no trailing slash ambiguity, and
   no relative/opaque URLs.
3. Replace public OAuth issuer/token/resource-link generation with this helper.
4. Keep non-public legacy helpers only where existing behavior requires them.

Gate:

- Public OAuth fails closed when the canonical base URL is absent or malformed.
- Discovery, token `instance_url`, token `id`, and identity links share one
  origin.

### Phase 3 - PKCE Storage And Token Exchange Substrate

Primary files:

- `backend/internal/domain/ports/repositories.go`
- `backend/internal/infrastructure/persistence/oauth_repository.go`
- `backend/internal/features/auth/oauth_service.go`
- `backend/internal/interfaces/rest/oauth/oauth_handler.go`
- focused OAuth tests

Work:

1. Replace string-argument auth-code APIs with request structs carrying client,
   redirect, scope, PKCE, issuer, consent, auth time, and client type.
2. Store code hash instead of plaintext code for new codes. A SHA-256 hash of a
   high-entropy random code is sufficient; use a server-side HMAC/pepper only if
   an existing secret-management pattern is available.
3. Use a hard cutover for plaintext codes: the migration deletes outstanding
   unused authorization codes and returns `invalid_grant` to clients that try
   to redeem pre-cutover codes. Codes are short-lived, so this is simpler and
   safer than a dual-read plaintext/hash path.
4. Keep consume atomic and one-time. Continue using transaction + row lock or
   an equivalent single-statement update/read that is safe under TiDB.
5. Add PKCE verifier/challenge helpers.
6. Add token exchange logic for `TokenEndpointAuthMethod=none` public clients.
7. Keep confidential authorization-code exchange compatible with current
   secret-based behavior.
8. Add safe OAuth error mapping for token failures; do not return `err.Error()`
   to clients.
9. Add token endpoint rate limits by IP and, when known, client id and reason
   category.
10. Add no-store token response headers.

Gate:

- Public auth code cannot be redeemed without the exact verifier.
- Confidential exchange behavior is unchanged unless explicitly migrated.
- Code reuse and expired code behavior is covered.
- Pre-cutover plaintext codes are invalidated deliberately and safely.
- Token errors are sanitized while logs/audit keep reason categories.

#### Consent-SSoT cutover exception (selected Option B)

The plaintext-code cutover above remains mandatory. Separately, the
consent-SSoT rollout permits a narrowly bounded exception for an already-hashed,
short-lived authorization code issued before `ConsentId` binding existed:

1. New authorization-code issuance always writes the immutable grant id. Only
   a historic row with an empty `ConsentId` can use this exception.
2. A historic code continues to pass the normal client, redirect, scope, PKCE,
   issuer, app-access, one-time-use, and expiry checks; it receives no policy
   bypass merely because it lacks the grant id.
3. Token redemption takes the Connected App → user serialization path before
   conditionally consuming the unused, unexpired code. Disconnect takes the
   same path and consumes every still-redeemable unbound historic code for that
   user across apps in its credential-revocation transaction.
4. Bound codes, expired codes, and codes for other users are not swept. Once
   the short code lifetime has elapsed, no redeemable unbound code remains;
   expired rows are intentionally left untouched and this compatibility
   exception disappears.

### Phase 4 - Public Connected App Policy And Authorize Flow

Primary files:

- OAuth handler/service
- Connected App repository/read helpers
- route tests under `backend/internal/interfaces/rest`
- service tests under `backend/internal/features/auth`

Work:

1. Validate public app configuration at authorize time.
2. Require explicit `scope` and S256 PKCE for public apps.
3. Preserve authorize request through login using server-owned request
   snapshots.
4. Add authorize endpoint rate limits by IP and, when known, client id and
   reason category.
5. Enforce exact redirect matching and reject unsafe URI shapes.
6. Add safe OAuth error mapping for authorize failures, including no untrusted
   error redirects.
7. Bind issued code to user/app/redirect/scope/issuer/client type.
8. Re-check current app policy at token exchange.

Gate:

- Missing PKCE, missing scope, invalid redirect, invalid app, inactive app,
  unsupported scope, and unsupported response type all fail with safe OAuth
  error behavior.
- Login and consent preserve exactly one server-owned request snapshot and
  cannot cross-approve another tab's request.

### Phase 5 - Consent, UX, And Revocation

Primary files:

- OAuth consent repository/service
- consent REST/UI endpoints
- Connected App admin/user APIs
- session repository revocation helpers
- records persistence hooks for Connected App and Connected App Access changes
- audit writer
- `frontend/src/pages/ConnectedAppManager.tsx`
- `frontend/src/features/connected-apps/*`
- `frontend/src/infrastructure/api/connectedApps.ts`

Work:

1. Add consent lookup/reuse/revoke helpers.
2. Add consent screen endpoints and CSRF-protected approve/deny POST.
3. Re-check consent at token exchange.
4. Add user consent inventory/revoke API.
5. Update Connected App create/edit UX for public/confidential policy, redirect
   URI lists, consent policy, publisher/support fields, and public no-secret
   behavior.
6. Add server-side confidential secret creation/rotation actions with one-time
   display.
7. Add admin app usage/session revocation actions.
8. Add user/app tuple session revoke helper.
9. Ensure Connected App and access-policy mutations revoke affected delegated
   sessions.
10. Add audit events for consent, code, token, revoke, secret rotation, and
    policy changes.

Gate:

- Public code issuance is impossible without consent or explicit trusted/admin
  preapproval.
- Consent revocation immediately kills live delegated sessions.
- Admins and users can view and revoke external app access before any general
  public-client launch.

### Phase 6 - Scope Registry And Route/Link Enforcement

Primary files:

- `backend/internal/interfaces/middleware/scope.go`
- route registration/middleware integration
- version-root and identity handlers
- CRM pack metadata route inventory

Work:

1. Add generic scope registry support for method + route template + link
   policy.
2. Load `external.records.read` route/link policy from the converged
   source-backed `oauth_custom_scope` row
   metadata.
3. Fail closed when metadata loading is not ready.
4. Use the registry for route enforcement and resource-link filtering.
5. Ensure empty public scope never reaches `ScopeEnforcer`.

Gate:

- `external.records.read` can read only the P1 route inventory.
- `external.records.read` cannot reach `/userinfo`, `/connect`, `/tooling`,
  `/ui-api`, `/analytics`, `/composite`, `/ws`, or write routes.
- Resource documents do not advertise links outside the granted scope.

### Phase 7 - Discovery Metadata

Primary files:

- route registration
- new authorization server metadata handler
- metadata contract tests

Work:

1. Add `/.well-known/oauth-authorization-server`.
2. Emit only completed P1 fields.
3. Add explicit absence tests for unsupported fields in the **non-MCP
   authorization-server metadata**: password, client credentials, refresh,
   OIDC, introspection, DCR, resource metadata, and product scopes. These
   tests must not assert absence of the MCP protected-resource endpoint, its
   `WWW-Authenticate` challenge, or conditional P2
   `scopes_supported` metadata.

Gate:

- Standards clients discover S256 authorization-code support without discovering
  any incomplete surface.
- Standards-based public clients remain feature-flagged/internal-only until
  this phase lands. Manual smoke tests may use direct endpoint configuration
  earlier, but MCP compatibility is owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

### Phase 8 - Operational Hardening

Work:

1. Add metrics for authorize/token success and failure reason categories.
2. Add audit dashboards/runbook entries for disabling an app and investigating
   delegated-token abuse.
3. Add centralized log redaction coverage.
4. Add the non-MCP local smoke script for verifier generation, auth, token
   exchange, route call, wrong verifier, code reuse, and app deactivation. MCP
   smoke acceptance is owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

Gate:

- Security owner can investigate a suspicious app without secret exposure in
  logs or metrics.

## 9. Follow-Up Programs

These must stay out of P1 unless separately designed:

| Program | Required before advertisement |
| --- | --- |
| Refresh tokens | `_System_OAuthRefreshToken`, hash storage, rotation on every use, family replay detection, user/admin revocation, `offline_access` semantics, metadata tests. |
| RFC 7009 standards revocation | Public/confidential caller rules, authentication behavior, same-app revocation limits, enumeration-safe responses, audit/rate limits. |
| RFC 7662 introspection | Protected-resource caller authorization, resource/audience checks, response claims, token-scanning limits, metadata tests. |
| OIDC | ID token, JWKS, key rotation, issuer/audience, nonce, subject policy, auth_time/max_age decisions, claims privacy, UserInfo contract, conformance tests. |
| Dynamic Client Registration | Who may register, redirect restrictions, publisher identity, app owner, rate limits, abandoned app cleanup, default scopes, admin review. |
| Non-MCP protected-resource metadata | Resource identifiers, token audience/resource storage, per-request enforcement, cross-resource rejection tests. MCP protected-resource metadata is owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md). |
| Browser CORS | Preflight app identity model or deployment-level trusted origins; no union of all app origins. |
| DPoP/mTLS | Sender constraint validation, key lifecycle, replay windows, fallback policy. |
| Device Authorization Grant | Headless CLI UX, polling limits, user code entropy, phishing controls. |
| Password-auth retirement | Replacement product login path, compatibility plan for known Salesforce libraries, docs cleanup. |
| Sysadmin login-as | Actor/subject session model, dedicated permission, reason/ticket, short TTL, sensitive-surface blocks, audit on every write. |

## 10. Verification Plan

### Backend Tests

- Public client completes Authorization Code + PKCE with no secret.
- Confidential client still requires a valid secret.
- Public client supplying `client_secret` is rejected.
- Missing/malformed PKCE fails at authorize or token as appropriate.
- `plain` PKCE fails for public clients.
- Public app with empty or unsupported scopes fails closed.
- Public request with omitted/empty scope fails.
- `api`, `full`, `api.read`, `chatter_api`, `openid`, custom, and unknown
  scopes fail for public apps.
- Authorization code cannot be reused.
- Expired authorization code cannot be redeemed.
- Code issued to one client cannot be redeemed by another.
- Code issued to one redirect URI cannot be redeemed with another.
- Code issued before app/redirect/scope/consent policy change fails if current
  policy no longer permits it.
- Invalid redirect URI is not used for error redirects.
- Redirect validation rejects fragments, wildcards, embedded credentials,
  non-HTTPS hosted redirects, relative URLs, unsupported schemes, and
  unregistered localhost port variance.
- Authorize request snapshot is TTL-bound, single-use, session-bound, and cannot
  be cross-approved from another tab.
- Consent reuse requires same user, same app, active consent, current
  `ConsentVersion`, and scope subset.
- Consent revoke kills live delegated sessions.
- App deactivation invalidates app-bound sessions.
- App-active/user-active caches are invalidated or bypassed so revocation is
  effective on the next API request.
- User deactivation or lock blocks exchange/API use.
- Connected App Access removal invalidates affected sessions.
- Generic data API, admin API, bulk update, and pack/apply Connected App changes
  all trigger the same validation, audit, cache invalidation, and revocation
  behavior.
- `external.records.read` route matrix allows only P1 read routes.
- No `case "external.records.read"` or `case "crm.data.read"` L0 scope branch
  or platform-binary code allowlist is added.
- Resource links are filtered by scope.
- Token response has no-store headers.
- Token response public URLs use canonical external base URL.
- Public authorization-code token responses omit `signature`.
- Confidential app secrets are server-generated, hash-at-rest, one-time display,
  and redacted from reads.
- Pre-cutover plaintext authorization codes are deliberately invalidated.
- A pre-grant, already-hashed authorization code is grandfathered only for its
  short lifetime; an app disconnect atomically consumes the user's remaining
  unbound historic codes across apps.
- OAuth error responses are sanitized and never return raw `err.Error()`.
- Logs redact token, code, verifier, password, client secret, and refresh-token
  field names/values.
- Metadata advertises only completed MVP fields and proves excluded fields are
  absent.

### Frontend Tests

- Creating a public app does not require or display a secret.
- Confidential app secret creation/rotation shows a server-generated secret once
  and redacts it after the action completes.
- Public app scope picker exposes only allowed public scopes.
- Redirect URI validation errors are visible.
- Consent screen displays app identity and requested scopes.
- Deny returns a safe OAuth error to the trusted redirect URI.
- User can revoke consent.
- Admin can deactivate app and see sessions invalidated.

### Smoke Test

1. Create public app with redirect URI `http://127.0.0.1:8080/callback`.
2. Generate RFC 7636 verifier/challenge.
3. Open `/services/oauth2/authorize` with `external.records.read`.
4. Approve consent.
5. Exchange code with verifier and no secret.
6. Call `/services/data/v63.0/sobjects`.
7. Verify wrong verifier, code reuse, insufficient scope, revoked consent, and
   app deactivation failures.

### Standard Commands

Use the app-packs root required by local policy when running backend tests that
touch control-plane packs:

```bash
export NEXUSCRM_OFFICIAL_APP_PACKS_ROOT=/Users/ywang/projects/nexuscrm-app-packs
(cd backend && go test ./internal/interfaces/rest ./internal/features/auth ./internal/infrastructure/persistence ./internal/interfaces/middleware)
(cd shared && go test ./...)
(cd frontend && npm run build)
git diff --check
```

If a focused implementation touches `backend/internal/interfaces/rest/`, also
run the relevant checks from `docs/design/salesforce-v63-api-contract.md`.

## 11. Rollout And Backout

Rollout:

1. Land schema fields with existing apps migrated to confidential behavior.
2. Land PKCE public-client behavior behind a feature flag.
3. Enable internal trusted/manual public-app smoke tests only after PKCE,
   consent, revocation, scope filtering, and admin/user revocation UX gates
   pass.
4. Add discovery metadata after the contract is complete; standards-based
   non-MCP discovery starts only after its absence tests pass. MCP compatibility
   is owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
5. Keep general third-party public app creation disabled until operational
   hardening and security review are complete.

Backout:

- Disable public app creation and public authorization-code exchange.
- Keep existing confidential apps and legacy grants unchanged.
- Remove or disable discovery metadata rather than broadening it.
- If consent breaks, public token issuance fails closed before session creation.
- If scope registry/link filtering breaks, public tokens cannot be issued.

## 12. Open Questions

1. What access-token TTL should public authorization-code sessions use?
2. Which future phase should add loopback port variance for native/CLI clients,
   if exact registered localhost redirects are too restrictive?
3. What is the product-approved consent copy and publisher verification policy?
4. Which deployment config key owns canonical external base URL?
5. Which app-pack delivery path installs the future `crm.data.read` route/link
   policy after object-pack constraints exist?
6. Should admin access-policy changes revoke all app sessions or targeted
   user/app sessions in P1?
7. Which admin page owns user consent inventory and revocation?
8. Should public apps have a shorter session TTL than confidential apps?
9. Should `client_credentials` get a separate machine-client discovery design?
10. Which future public scope follows `external.records.read`: CRM-pack
    `crm.data.read`, writes, Connect, UI API, analytics, custom route scopes, or
    OIDC?
