# Design - OAuth Authorization Code + PKCE for External Apps

**Status:** DRAFT (for review)
**Date:** 2026-06-01
**Related issue:** #1702

**Scope:** NexusCRM as an OAuth authorization server for external dashboards,
local tools, browser apps, CLIs, MCP/Claude-style clients, and agent-built apps
that call NexusCRM API endpoints. This document is design-only; it changes no
runtime behavior.

> **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.

**Layout note:** frontend path references in this design 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`.

**One-line milestone:** NexusCRM supports standards-based public clients through
Authorization Code + PKCE, so an external app can get a user-approved,
Connected-App-bound, scoped API token without ever receiving a password, admin
token, or static client secret.

---

## 0. Bottom line

NexusCRM already has the right outer shape for this feature, but it is not yet a
safe public-client authorization server.

The proposal is therefore not "add a PKCE parameter." It is a security design
for a new external-app authorization surface:

1. **Ship the smallest complete public-client contract first.** The first
   shippable public-client phase includes Authorization Code + S256 PKCE,
   explicit user consent or trusted/admin-preapproved app policy, and
   revocation. Advertise only completed OAuth/OIDC surfaces in discovery.
2. **Keep current legacy/bootstrap grants unadvertised.** The existing
   `password` grant, current `client_credentials` path, current
   `/userinfo`, and current `/introspect` helper must not become
   machine-discoverable public integration contracts from this PKCE design.
3. **Bind every authorization code to one app, redirect URI, user, scope, and
   PKCE challenge.** A stolen code must be useless without the original verifier
   and the stored request context.
4. **Separate app access, user consent, and data authorization.** Connected App
   policy decides whether a user may use an app; consent records decide whether
   the user approved a scope; API permissions, FLS, RLS, and sharing
   still decide what records the token can access.
5. **Treat password-auth retirement and sysadmin login-as as identity-roadmap
   work.** PKCE removes the need to give external apps passwords. It does not
   by itself replace product login, password reset, or audited support
   impersonation.

The blocking review feedback is correct: discovery metadata is a public
contract. This document now makes the advertised MVP contract intentionally
narrow and moves introspection, OpenID Connect, refresh tokens, Dynamic Client
Registration, and login-as behind explicit gates.

## 1. Design-doc planning bar

A useful design doc for this kind of change must answer more than "which
endpoints do we add?" The planning bar used here is:

| Aspect | What this doc must make reviewable |
| --- | --- |
| Problem and audience | Who needs the capability, why existing flows are unsafe or insufficient, and which users/admins/apps are affected. |
| Current state | Concrete code paths, schemas, and behaviors that the design depends on. |
| Goals and non-goals | The first shipping contract, what remains legacy, and what is explicitly deferred. |
| Standards and constraints | OAuth/OIDC/MCP/Salesforce requirements and NexusCRM compatibility constraints. |
| Options and tradeoffs | Why PKCE is chosen and why password grant, static tokens, full OIDC, DCR, and refresh tokens are not first-cut requirements. |
| Target architecture | Actors, trust boundaries, request flow, state transitions, and authoritative data owners. |
| API/data contracts | Exact endpoint behavior, discovery metadata, storage changes, validation rules, errors, and revocation semantics. |
| Security and privacy | Threat model, abuse cases, consent, least privilege, secret handling, logging, monitoring, and incident controls. |
| Rollout and migration | Phase order, compatibility promises, backout plan, and gates proving each phase is ready. |
| Verification | Unit, integration, negative, browser/client, operational, and security-review checks. |
| Open questions | Product/security decisions that must not be guessed by implementation PRs. |

This follows the same principle as code review: write the decision so reviewers
can challenge a concrete contract, not infer intent from prose.

## 2. Security planning bar

Security functionality needs a higher bar because mistakes become public
contracts, token leaks, consent bypasses, or support-audit gaps. For this
proposal, the required security aspects are:

| Aspect | Required design content |
| --- | --- |
| Assets | Passwords, authorization codes, access tokens, future refresh tokens, Connected App secrets, CRM data, consent records, audit logs, and login-as context. |
| Actors | End user, external public client, confidential server client, MCP/agent host, Connected App admin, sysadmin support actor, protected API, attacker. |
| Trust boundaries | Browser to NexusCRM, app redirect URI, token endpoint, protected API, admin UI, database, logs, and future MCP resource metadata. |
| Authentication | How the user authenticates, how confidential clients authenticate, and why public clients do not have secrets. |
| Authorization | App policy, user consent, OAuth scopes, API route scopes, object permissions, field permissions, RLS/sharing, and admin login-as authority. |
| Token lifecycle | Issuance, TTL, binding, storage, revocation, app deactivation, user deactivation, refresh-token rotation when implemented. |
| Abuse cases | Code interception, code injection, open redirect, redirect URI mix-up, token replay, consent phishing, future CORS abuse, CSRF, token scanning, app impersonation, log leakage, and support impersonation abuse. |
| Least privilege | Scope narrowing, future resource indicators, app access grants, consent revocation, and no default broad token for new public apps. |
| Secret handling | Do not store PKCE verifiers; hash codes and refresh tokens; redact tokens, codes, secrets, passwords, and verifiers from logs. |
| User/admin control | Consent screens, app inventory, revoke app access, revoke tokens, deactivate apps, and audit app policy changes. |
| Observability | Security audit events, metrics, anomaly signals, rate limits, and enough correlation ids to investigate without logging secrets. |
| Verification | Negative tests for every rejected path, metadata contract tests, threat-model review, and phase gates before advertising new standards surfaces. |

## 3. The issue

Users want to authorize tools outside the NexusCRM UI to call NexusCRM APIs.
The concrete target is an external dashboard or app built in an environment
such as Claude CoWork, Claude Code, a browser-based app builder, Postman, or a
local CLI.

Those clients are usually **public clients**:

- their code runs in a browser, local process, or user-controlled workspace
- they cannot keep `ConsumerSecret` confidential
- asking the user for their NexusCRM password trains unsafe behavior
- minting long-lived admin tokens bypasses user consent, scopes, app policy,
  session revocation, and audit boundaries
- agent hosts may store or replay tokens, so token scope and lifecycle must be
  explicit

The industry-standard answer for delegated user access is Authorization Code +
PKCE:

1. The client generates a per-request `code_verifier`.
2. The client sends a derived `code_challenge` on `/authorize`.
3. NexusCRM stores the challenge with the authorization code.
4. The client redeems the code by proving possession of the verifier.

PKCE protects the authorization code exchange for public clients. It does not
replace user permissions, app access policy, scopes, consent, future CORS,
token revocation, or audit logging.

## 4. Design-time inventory (2026-06-01)

This inventory records the design baseline, not current launch instructions.
The single-process server has since been retired; use
[the Runtime guide](../../infra/runtime/README.md) and the L1 AppService.

| Surface | Current behavior | Gap for public clients |
| --- | --- | --- |
| OAuth routes | `backend/cmd/server/routes.go` exposes `/services/oauth2/authorize`, `/token`, `/revoke`, `/introspect`, `/userinfo`, Google token exchange, and password-management endpoints. | The route set is broader than the safe public discovery contract. Discovery must not advertise every route just because it exists. |
| `/services/oauth2/authorize` | `backend/internal/interfaces/rest/oauth/oauth_handler.go` accepts `response_type=code`, uses optional auth, redirects unauthenticated users to `/login`, and calls `GenerateAuthCode`. | No `code_challenge`, no `code_challenge_method`, no explicit consent step, and no issuer response parameter. |
| Authorization-code service | `backend/internal/features/auth/oauth_service.go` validates one `CallbackUrl`, generates a random code, stores it for 10 minutes, and later requires `client_secret`. | The code is confidential-client shaped. Public clients cannot redeem without a secret, and the code is not bound to PKCE metadata. |
| OAuth code persistence | `backend/internal/infrastructure/persistence/oauth_repository.go` stores plaintext `Code`, `ClientId`, `RedirectUri`, `Scope`, `UserId`, `Nonce`, expiry, and `Used`; `ConsumeAuthCode` uses a transaction and `FOR UPDATE`. | One-time consume is a strong base, but codes are stored plaintext and lack PKCE challenge, method, auth time, and consent id. |
| `/services/oauth2/token` | Supports `authorization_code`, `password`, and `client_credentials`; accepts form or JSON; returns Salesforce-compatible token fields. | `authorization_code` requires `client_secret`; `password` is a legacy/bootstrap flow that must stay unadvertised; `client_credentials` is a separate machine-client contract, not this public-client design. |
| `_System_ConnectedApp` | Stores `ConsumerKey`, `ConsumerSecret`, `CallbackUrl`, `Scopes`, `IsActive`, `RunAsUserId`, `PermittedUsers`, and API limits. | No `ClientType`, PKCE requirement, token-endpoint auth method, redirect URI list, consent policy, grant allowlist, or refresh-token policy. Cross-origin browser CORS needs a separate preflight/app-identification design. |
| `_System_Session` | Stores user, token, expiry, activity, scope, connected app id, grant type, and revocation state. `ValidateSession` re-checks user active state, Connected App active state, and DB-backed scope/app id. | No actor/subject split for future login-as and no refresh-token family state. |
| Scope enforcement | `backend/internal/interfaces/middleware/scope.go` treats `api`/`full` as broad access, `api.read` for reads, `chatter_api` for connect routes, and `openid` only for `/userinfo` and `/id/:orgId`. | `openid` currently gates a helper endpoint but does not imply a complete OIDC contract. Custom scopes are not route-mapped yet. |
| Scope fallback | `ComputeEffectiveScope` treats empty app scopes as full app access and empty requested scopes as "all app scopes"; `ScopeEnforcer` treats empty session scope as full API access. | This compatibility behavior must be closed for new public apps. A public app with empty allowed scopes or an empty requested/granted scope is invalid, not full access. |
| Public URL generation | Token responses, REST helpers, and version-root resource links currently derive base URLs through separate helpers and request/proxy context. | Discovery issuer, token `instance_url`, token `id`, and identity/resource links need one configured canonical external base URL for the public OAuth contract. |
| Connected App admin UI | `frontend/src/pages/ConnectedAppManager.tsx` and `frontend/src/features/connected-apps` manage current app fields and access policies. | Admins cannot create a public app without a secret, register multiple redirect URIs, see consent, or distinguish delegated-user apps from machine clients. |
| `/services/oauth2/introspect` | Exists and is guarded by generic bearer `requireAuth`; it returns activity for a submitted token. | RFC 7662 introspection is a protected-resource caller contract. The current helper must not be advertised until caller authorization, rate limits, audit, and tests are designed. |
| `/services/oauth2/userinfo` | Exists and returns Salesforce/OIDC-like user information behind `requireAuth` and `ScopeEnforcer`. | Advertising `openid` or `userinfo_endpoint` would imply an OIDC contract with ID Token, nonce, JWKS, claims, and discovery semantics that this design does not ship. |

**Current strongest asset:** authorization codes are already short-lived and
one-time-use via transactional consume.

**Current strongest blocker:** Connected Apps do not distinguish public clients
from confidential clients, so the token endpoint has no policy basis for
allowing `authorization_code` without a secret.

**Current contract risk:** routes that exist for compatibility are easy to
over-advertise in standards metadata. This design treats discovery as an
allowlist of finished contracts, not a reflection of every handler in the
server.

## 5. Standards and external-client baseline

This design follows the current OAuth security baseline rather than a
Nexus-only flow:

- **RFC 7636 (PKCE):** `code_verifier` is 43-128 characters from the RFC 3986
  unreserved set. `S256` is
  `BASE64URL(SHA256(ASCII(code_verifier)))`; clients capable of S256 use it.
- **RFC 9700 (OAuth 2.0 Security BCP):** public authorization-code clients use
  PKCE; exact redirect URI matching is required except for bounded localhost
  port handling for native apps; open redirectors are forbidden.
- **RFC 8252 (Native Apps):** native/local apps use an external user agent and
  PKCE; embedded webviews are unsafe for third-party authorization.
- **RFC 8414 (Authorization Server Metadata):** discovery metadata is a machine
  contract. Only advertise endpoints, grants, scopes, and auth methods that
  NexusCRM is ready to support as public standards behavior.
- **RFC 9728 and RFC 8707:** the MCP-specific protected-resource, client-supplied
  resource-indicator, and audience contract is owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
  This general external-app design does not define an MCP resource profile.
- **RFC 7662 (Token Introspection):** introspection is for protected resources
  calling an authorization server and must have its own caller authorization.
- **OpenID Connect Core:** advertising `openid` or `userinfo_endpoint` implies
  identity semantics, `id_token`, issuer/audience validation, and related
  nonce/JWKS/claims behavior. Do not advertise OIDC partially.
- **OWASP OAuth guidance:** PKCE protects authorization codes; access and
  refresh tokens still need audience restriction, short TTLs, sender
  constraints or rotation, and least privilege.
- **OWASP threat modeling guidance:** security design starts with the system
  model, trust boundaries, threat identification, mitigations, and validation.
- **NIST SSDF:** security requirements and threat analysis belong in the SDLC,
  not only in code review after implementation.
- **MCP clients:** MCP connection and credential
  requirements are owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md),
  not this general external-app contract.

**Design consequence:** S256 PKCE is mandatory for new public clients. The MVP
metadata advertises only the completed Authorization Code + PKCE public-client
contract.

## 6. Goals and non-goals

### Goals

- Let a third-party or user-built public client get a delegated NexusCRM API
  token without a password or client secret.
- Keep the returned access-token response compatible with existing
  Salesforce-style clients where possible.
- Let admins create and manage public Connected Apps separately from
  confidential Connected Apps.
- Bind authorization codes to S256 PKCE and to the original redirect URI,
  client, user, and scope.
- Add user consent and admin preapproval as separate policy layers.
- Publish discovery metadata only for contracts implemented and verified by
  this program.
- Adopt the MCP public-client contract only through
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
- Preserve existing confidential-client behavior unless a later migration PR
  explicitly changes a known client.

### Non-goals for the first shipping PKCE release

- Full OpenID Connect provider behavior.
- Advertising `openid`, `userinfo_endpoint`, `jwks_uri`, or OIDC discovery.
- Advertising or relying on the current `/services/oauth2/introspect` helper.
- Dynamic Client Registration or Client ID Metadata Documents.
- MCP client-supplied resource indicators and protected-resource metadata;
  their scope 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 before rotation/family replay detection.
- Sender-constrained access tokens through DPoP or mTLS.
- Password-auth retirement.
- Sysadmin login-as implementation.
- Rewriting Salesforce-compatible API permission, FLS, RLS, sharing, or record
  access semantics.

## 7. Target architecture

```text
External app / local tool / browser dashboard
    |
    | 1. GET /services/oauth2/authorize
    |    response_type=code
    |    client_id=<public connected app>
    |    redirect_uri=<registered redirect>
    |    scope=<non-MCP public-app scope>
    |    state=<client csrf binding>
    |    code_challenge=<S256 challenge>
    |    code_challenge_method=S256
    v
NexusCRM authorization endpoint
    |
    | validates app, redirect URI, non-empty scope, and PKCE challenge
    | authenticates user in browser if needed
    | checks app access policy
    | collects consent if required
    | stores one-time code hash + request metadata
    v
Redirect URI receives code and state
    |
    | 2. POST /services/oauth2/token
    |    grant_type=authorization_code
    |    client_id=<public connected app>
    |    redirect_uri=<same registered redirect>
    |    code=<one-time code>
    |    code_verifier=<original verifier>
    v
NexusCRM token endpoint
    |
    | consumes code atomically
    | verifies S256(code_verifier)
    | creates scoped Connected-App-bound session
    v
Bearer token calls /services/data/v63.0/*
```

The session remains a NexusCRM session. PKCE changes how the user-approved
session is minted; it does not bypass:

- Connected App active state
- `PermittedUsers` / admin-approved app access
- Connected App access-policy changes that revoke affected active sessions
- user consent and consent revocation
- OAuth scopes
- `ApiEnabled`
- object permissions
- field permissions
- RLS / sharing
- app rate limits
- user active state
- session revocation

## 8. Key decisions

### Decision 1 - Discovery is a completed-contract allowlist

Authorization server metadata must not list endpoints or grants just because a
handler exists. It lists only the public standards surface this program has
designed and tested.

MVP discovery includes:

- `issuer`
- `authorization_endpoint`
- `token_endpoint`
- `response_types_supported: ["code"]`
- `grant_types_supported: ["authorization_code"]`
- `code_challenge_methods_supported: ["S256"]`
- `token_endpoint_auth_methods_supported: ["none", "client_secret_post"]`

MVP discovery does not include:

- `password`
- `refresh_token`
- `client_credentials` unless a separate machine-client discovery contract is
  accepted
- `openid`
- `userinfo_endpoint`
- `introspection_endpoint`
- `registration_endpoint`
- `jwks_uri`
- `offline_access`
- `scopes_supported` until NexusCRM deliberately chooses to publish the public
  scope allowlist as metadata

Consequence: standards-based clients do not discover and depend on transition
or incomplete surfaces, and later additions can be gated by tests.

### Decision 2 - Add explicit Connected App client type

Connected Apps declare whether they are public or confidential.

| Client type | Token endpoint auth | PKCE | Primary use |
| --- | --- | --- | --- |
| `public` | `none` | required | Browser apps, local dashboards, native apps, agent-built tools |
| `confidential` | `client_secret_post` in MVP | recommended; optionally required | Server-side integrations that can protect a secret |

Migration:

- existing Connected Apps migrate as `confidential`
- the platform-owned `nexuscrm-ui` app stays on current behavior until first
  party login is redesigned deliberately
- new admin-created external dashboard apps default to `public` and
  `RequirePkce=true`

### Decision 3 - Require S256 PKCE for public clients

For `public` clients and any app with `RequirePkce=true`, `/authorize`
requires:

- `code_challenge`
- `code_challenge_method=S256`

`/token` then requires a valid `code_verifier`. If verification fails, return
`invalid_grant`.

NexusCRM validates verifier grammar before hashing:

```text
length: 43-128
characters: ALPHA / DIGIT / "-" / "." / "_" / "~"
entropy guidance: at least 256 bits from a cryptographic random source
```

Do not support `plain` for new public clients.

### Decision 4 - Public app scopes are allowlisted

This decision covers the general, non-MCP external API public-client surface.
MCP does not inherit this scope allowlist or a mandatory MCP scope. Its user
identity and connection contract are owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

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 P1 public-client scope allowlist is exactly:

- `external.records.read`

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

- `crm.data.read`

Public Connected Apps cannot be configured with scopes outside that allowlist,
and public `/authorize` requests cannot request scopes outside that allowlist.
Reject the existing broad scopes `api.read`, `api`, `full`, `chatter_api`,
`openid`, empty scopes, custom scopes not explicitly mapped below, and unknown
scopes for public apps until a later design defines route mapping, consent
text, verification, and discovery behavior for each additional scope.

Consequence: first-cut public apps support read-only dashboards and local tools.
Write-capable apps, Connect API access, OIDC sign-in, custom route scopes, and
full-access integrations require separate scope decisions and tests.

Do not publish the non-MCP public-app scope allowlist as authorization-server
`scopes_supported` metadata in P1. This does not constrain the MCP
protected-resource metadata contract, which is owned by
[L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

Scope-to-resource authorization is generic L0 substrate, but public scope names
and route inventories are metadata policy. The P1 external-records scope is
declared by the CRM app pack as source-backed `oauth_custom_scope` metadata and
converged by the generic control plane into `_System_OAuthCustomScope`.
CRM-specific scope names and route inventories remain L1 app-pack policy. The
platform should read this through a metadata-driven scope registry, for example:

| Registry field | Meaning |
| --- | --- |
| `ScopeName` | Client-facing OAuth scope string. |
| `ClientTypes` | Which client types may request the scope. |
| `Methods` and `RoutePatterns` | Exact protected-resource patterns covered by the scope. |
| `ResponseLinkPolicy` | Which resource-document links may be emitted for this scope. |
| `PackOwner` and `PackVersion` | Owning app pack and version. |

The CRM app pack owns the current P1 `external.records.read` payload; L0 owns
the generic apply/verify/read substrate. A different app pack can declare its
own public scope and route mapping without changing the OAuth substrate once the
policy model supports the needed constraints.
The same registry is the source of truth for both request authorization and
resource-document link advertisement; a route or link that is not mapped is not
publicly reachable or advertised to the client.

`external.records.read` is not an alias for the current `api.read` scope. It
has an explicit metadata route inventory:

| Route pattern | Decision |
| --- | --- |
| `GET/HEAD /services/data` | In |
| `GET/HEAD /services/data/:version` | In |
| `GET/HEAD /services/data/:version/sobjects` | In |
| `GET/HEAD /services/data/:version/sobjects/:sobject/describe` | In |
| `GET/HEAD /services/data/:version/sobjects/:sobject/:id` | In |
| `GET/HEAD /services/data/:version/query` | In |
| `GET/HEAD /services/data/:version/queryAll` | In |
| `GET/HEAD /services/data/:version/query/:locator` | In |
| `GET/HEAD /id/:orgId/:userId` | In as the Salesforce-compatible identity URL returned in the token response and version-root resource list. This is not OIDC UserInfo. |
| `GET/HEAD /services/data/:version/id/:orgId/:userId` | In as the versioned identity alias. |
| `/services/oauth2/userinfo` | Out; OIDC/UserInfo is not part of P1. |
| `/ws`, `/cometd/*`, `/api/*`, `/connect/*`, `/tooling/*`, `/ui-api/*`, `/analytics/*`, metadata mutation, file download, and all write routes | Out until separately mapped. |
| Any future route | Out by default until a design updates this inventory and adds tests. |

Resource-document responses must be filtered by the same registry. For
`external.records.read` public sessions:

- `/services/data/:version` returns only links covered by the granted scope:
  `sobjects`, `query`, `queryAll`, and `identity`
- `/id/:orgId/:userId` and the versioned identity alias return only identity
  fields and URLs covered by the granted scope: no SOAP links, profile links,
  recent links, Connect links, Tooling links, Analytics links, UI API links, or
  UserInfo links
- future resource links are hidden from public tokens until the CRM pack maps
  them to an allowed scope and adds tests

If implementation cannot externalize the CRM route inventory into pack metadata
in P1, that must be called out as a transition-only compromise and paired with a
named extraction task. The target shape remains pack-owned policy read through
generic L0 scope-registry substrate.

### Decision 5 - Keep redirect URI validation exact by default

Hosted web apps use exact string matching against registered redirect URIs.

Native/local public clients may later opt into localhost loopback port variance
only when the Connected App explicitly declares a native/CLI redirect policy.
Do not let a broad localhost exception apply to hosted web apps.

Invalid or unknown redirect URIs must not receive OAuth error redirects. Return
JSON or an internal error page instead.

### Decision 6 - Consent is separate from admin app access

`PermittedUsers=AdminApproved` answers whether a user is allowed to use the app.
Consent answers whether the user approved this app and scope.

Public third-party apps show consent unless the app is trusted or
admin-preapproved. Consent records are revocable independently of the app
record.

Connected App access policy is authoritative for active delegated sessions. In
the MVP, the policy mutation path enforces that authority: removing an app
access grant, changing `PermittedUsers`, or changing a profile/permission-set
entitlement that previously satisfied `AdminApproved` immediately revokes all
affected `(user, ConnectedApp)` authorization-code sessions. Request-time
session validation may later re-check `IsUserPermittedForApp`, but the MVP must
not require an operator to perform a second manual session revoke after removing
app access.

Consent reuse has one read path. An existing consent record may skip the prompt
only when all of these are true:

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

Scope expansion, consent-version changes, revoked consent, app mismatch, and
user mismatch force a new consent prompt. A prior app-level consent without
scope coverage is not sufficient.

### Decision 7 - Refresh tokens require hashing, rotation, and family replay detection

Long-lived public-client connectors need refresh tokens, but issuing static
refresh tokens to public clients is the wrong first cut. MCP refresh-token
requirements are owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

Status: **implemented.** The refresh-token grant now stores only refresh-token
hashes (`_System_OAuthRefreshToken`), rotates single-use on every exchange via an
atomic compare-and-swap, and carries a `FamilyId` so that reuse of an
already-rotated token revokes the whole family. The refresh effective scope is
also re-capped to the Connected App's current scopes on each exchange, so scope
narrowed after issuance is honored. With those in place, a Connected App may set
`IssueRefreshTokens=true` (public clients still require PKCE, and the app must
declare the `refresh_token` grant), the token endpoint returns `refresh_token`,
and discovery metadata advertises the `refresh_token` grant. `offline_access` is
still not advertised as a scope.

### Decision 8 - OIDC is not part of the PKCE MVP

Do not advertise `openid`, `userinfo_endpoint`, `jwks_uri`, or OIDC discovery
until NexusCRM designs a complete OIDC provider contract:

- `id_token` issuance
- signing keys and rotation
- issuer and audience claims
- subject identifier policy
- nonce handling where required
- auth time and max age semantics if supported
- claim mapping and privacy policy
- UserInfo response contract
- OIDC conformance tests

The existing `/services/oauth2/userinfo` route may remain as a
Salesforce-compatible helper, but it is not a discovered OIDC contract from
this design.

### Decision 9 - Introspection is deferred until protected-resource auth exists

Do not advertise `introspection_endpoint` in RFC 8414 metadata until NexusCRM
has a caller authorization model for protected resources.

The future introspection contract must define:

- which callers may introspect tokens
- whether callers authenticate with confidential client auth, a dedicated
  resource-server credential, mTLS/DPoP, or a privileged scope
- whether a caller may introspect only tokens for its own resource/audience
- token-scanning rate limits
- response claims
- audit events
- tests for unauthorized callers and cross-resource introspection

The current generic-bearer-protected handler is not sufficient as a public RFC
7662 endpoint.

### Decision 10 - Dynamic registration is not required for MVP

Admins can pre-create public Connected Apps for the first general external-app
release. Whether that is sufficient for an MCP client is owned by
[L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

Dynamic Client Registration or Client ID Metadata Documents are useful future
work, but they need an abuse-resistant registration policy:

- who is allowed to register an app
- redirect URI restrictions
- software statement or publisher identity, if any
- app owner and support contact
- rate limits and cleanup for abandoned apps
- default scopes
- whether public DCR apps require user consent every time
- admin review and revocation workflow

## 9. Alternatives considered

| Option | Verdict | Reason |
| --- | --- | --- |
| API keys or static bearer tokens | Reject | They are easy to paste into app builders but bypass user consent, scope, Connected App policy, normal session revocation, and least-privilege design. |
| Username/password grant for external apps | Reject and keep unadvertised | It trains users to share credentials with tools and becomes hard to remove once clients discover it. |
| Client credentials for user dashboards | Reject for delegated user access | It represents an app or run-as user, not the end user sitting at the dashboard. |
| Authorization Code without PKCE for public clients | Reject | Public clients cannot protect secrets, and modern OAuth security guidance requires PKCE. |
| Full OIDC provider first | Defer | Useful later, but it is larger than delegated API access and would require ID Tokens, keys, nonce/claims, and OIDC discovery/conformance. |
| Refresh tokens in MVP | Defer | Useful for long-lived connectors, but public-client refresh tokens require rotation and replay detection. |
| Introspection in MVP metadata | Defer | Current handler has the wrong caller model for a public RFC 7662 endpoint. |
| Dynamic Client Registration first | Defer | Pre-created public Connected Apps are enough to validate PKCE; DCR adds app-registration abuse and governance questions. |
| Device Authorization Grant for CLIs | Defer | Useful for headless CLIs, but the first use case can use browser authorization with loopback redirects. |

## 10. Data model changes

### `_System_ConnectedApp`

Add:

| Field | Shape | Notes |
| --- | --- | --- |
| `ClientType` | `public` / `confidential` | Default existing rows to `confidential`. |
| `RequirePkce` | boolean | Default true for public apps. |
| `TokenEndpointAuthMethod` | `none`, `client_secret_post` | `none` only valid for public apps. `client_secret_basic` is a future addition only after the Basic-auth parsing, precedence, and verification contract exists. |
| `AllowedGrantTypes` | JSON list or normalized set | Existing confidential/bootstrap rows may retain legacy flows during transition. New public external apps start with `authorization_code`. |
| `RedirectUris` | JSON list or child table | Authoritative when populated; legacy `CallbackUrl` backfills this. |
| `NativeRedirectPolicy` | enum / nullable | Optional future policy for loopback port variance; absent means exact matching. |
| `ConsentPolicy` | `prompt`, `trusted`, `admin_preapproved` | Controls whether user consent can be skipped. |
| `IssueRefreshTokens` | boolean | Default false until rotation exists. |
| `PublisherName` | string / nullable | Displayed on consent screens. |
| `SupportUrl` | URL / nullable | User/admin help and app identity review. |

Keep `CallbackUrl` as a compatibility field during migration. When both
`CallbackUrl` and `RedirectUris` exist, `RedirectUris` is authoritative.

Existing `Scopes` remains the allowed-scope owner. For new public apps it must
be explicit, non-empty, and a subset of the P1 public scope allowlist
(`external.records.read`). Do not inherit the current empty-scope compatibility
fallback, because that fallback means full API access in the current runtime.

### `_System_OAuthCode`

Add:

| Field | Notes |
| --- | --- |
| `CodeHash` | Store hash of code; stop relying on plaintext lookup after migration. |
| `CodeChallenge` | Stored from `/authorize`. |
| `CodeChallengeMethod` | `S256` for all new PKCE codes. |
| `AuthTime` | User authentication time. |
| `Issuer` | Issuer used for this authorization transaction. |
| `ConsentId` | Link to approved consent when consent is required. |
| `ClientTypeAtIssue` | Snapshot for safer token exchange and audit. |

`Nonce` can remain for future OIDC use. It is not the PKCE verifier. NexusCRM
must never store `code_verifier`.

### `_System_OAuthConsent`

Add a durable consent table:

| Field | Notes |
| --- | --- |
| `UserId` | Approving user. |
| `ConnectedAppId` | App receiving access. |
| `Scope` | Canonical normalized granted scope set. Prior consent covers a request only when the requested scope set is a subset of this value and all other consent-reuse predicates match. |
| `ApprovedAt` | Initial approval. |
| `LastUsedAt` | Updated on successful token exchange or refresh. |
| `RevokedAt` | User/admin revocation. |
| `RevokedBy` | User/admin/system actor. |
| `ConsentVersion` | Allows policy/UI changes to force reprompt. |

### `_System_OAuthRefreshToken`

Add only in the refresh-token phase:

- hashed refresh token
- user id, session id, connected app id
- token family id
- scope
- issued, last-used, expiry, revoked timestamps
- rotation counter or previous-token pointer
- client id and client type snapshot
- reuse-detected timestamp and reason

Public-client refresh tokens must rotate. Reuse of an already rotated token
revokes the token family.

### `_System_LoginAsSession`

Add only in the login-as phase or model equivalent fields on `_System_Session`:

- actor user id
- subject user id
- reason/ticket
- started, ended, expiry timestamps
- source IP and user agent
- policy decision id
- blocked sensitive operation count

This is not an OAuth grant. It is an audited platform session type.

## 11. Endpoint contracts

### `/services/oauth2/authorize`

Request:

```text
GET /services/oauth2/authorize?
  response_type=code&
  client_id=...&
  redirect_uri=...&
  scope=external.records.read&
  state=...&
  code_challenge=...&
  code_challenge_method=S256
```

Behavior:

1. Validate `client_id` and load an active Connected App.
2. Validate `response_type=code`.
3. Validate `redirect_uri` against registered `RedirectUris`.
4. For public apps, require the Connected App to have an explicit non-empty
   allowed-scope set that is a subset of the public scope allowlist
   (`external.records.read` in P1).
5. For public apps, require an explicit non-empty `scope` request. Do not
   default omitted public-client scope to all app scopes.
6. Validate requested scopes as a non-empty subset of app scopes and the public
   scope allowlist.
7. For public/PKCE-required apps, require `code_challenge_method=S256`.
8. Validate `code_challenge` grammar.
9. Authenticate the user if no browser session exists.
10. Preserve the original authorize request through login.
11. Check `PermittedUsers` / app access policy.
12. Show consent unless trusted/admin-preapproved policy applies or a single
    existing consent record satisfies the consent-reuse rule: same user, same
    app, unrevoked, current `ConsentVersion`, and requested scope subset of the
    stored consent scope.
13. Generate a high-entropy one-time code.
14. Store code hash, client, redirect URI, user, scope, issuer, PKCE
    challenge, consent, expiry, and `Used=false`.
15. Redirect to the trusted callback with `code` and original `state`.

Public authorization-code flows must never persist an empty granted scope. An
empty public `Scope` on `_System_OAuthCode` or `_System_Session` is a rejected
state, not a synonym for full access. Existing confidential/bootstrap clients
may keep their legacy compatibility behavior until intentionally migrated.

Error behavior:

- unknown `client_id`: JSON `invalid_client`; do not redirect
- invalid/unregistered `redirect_uri`: JSON `invalid_request`; do not redirect
  to the untrusted URI
- trusted callback known: redirect request errors with `error`,
  `error_description`, and `state`
- unsupported challenge method: `invalid_request`
- missing public-client PKCE challenge: `invalid_request`
- missing/empty public-client `scope`: `invalid_scope`
- public app with empty or unsupported allowed scopes: `invalid_scope`
- public request for unsupported scopes such as `api`, `full`, `chatter_api`,
  `openid`, custom scopes, or unknown scopes: `invalid_scope`
- app access denied after user authentication: `access_denied`

Server-side CSRF:

- The client-owned `state` value is returned unchanged.
- The consent POST uses normal NexusCRM CSRF protection.
- The authorization request snapshot must be bound to the authenticated user
  session before consent approval.

### `/services/oauth2/token`

Request:

```text
POST /services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
client_id=...&
code=...&
redirect_uri=...&
code_verifier=...
```

Behavior:

1. Accept `application/x-www-form-urlencoded`; keep JSON as a compatibility
   extension while current clients rely on it.
2. Validate `grant_type=authorization_code`.
3. Load active Connected App by `client_id`.
4. Authenticate the client according to `TokenEndpointAuthMethod`.
5. For public clients, reject supplied `client_secret` rather than treating it
   as meaningful.
6. Consume the authorization code atomically.
7. Validate code expiry and one-time use.
8. Validate code-bound `client_id`, `redirect_uri`, scope, and issuer.
9. Require and validate `code_verifier` when the code has PKCE metadata.
10. Re-check app active state and user access policy.
11. Re-check consent using the same consent-reuse rule before issuing a token.
12. For public authorization-code flows, reject empty code-bound or session
    scope instead of inheriting full-access fallback behavior.
13. Create a session with `ConnectedAppId`, `Scope`, and
    `GrantType=authorization_code`.
14. Return the existing Salesforce-compatible token response.

Response:

```json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 7200,
  "scope": "<granted scopes>",
  "instance_url": "https://nexus.example.com",
  "id": "https://nexus.example.com/id/00D.../005...",
  "issued_at": "1780310400000"
}
```

Do not return `refresh_token` until refresh-token rotation exists.

The public-client MVP keeps `id` because it is part of the existing
Salesforce-compatible token response, but the URL must be dereferenceable by the
same `external.records.read` token. The supported identity surface is the
Salesforce-compatible `/id/:orgId/:userId` route, with
`/services/data/:version/id/:orgId/:userId` retained as a versioned alias. It is
not `/services/oauth2/userinfo` and not OIDC UserInfo.

### `/.well-known/oauth-authorization-server`

Minimum 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"]
}
```

Notably absent from the MVP response:

- `password`
- `client_credentials`
- `refresh_token`
- `offline_access`
- `openid`
- `userinfo_endpoint`
- `introspection_endpoint`
- `registration_endpoint`
- `jwks_uri`
- `scopes_supported`

Those absences are deliberate. They are gates, not omissions.

One configured canonical external NexusCRM base URL is the sole source for
public OAuth URLs:

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

Do not derive any of those values from request `Host`, `X-Forwarded-Host`, or
parallel helpers. If the configured external base URL is unset, malformed, or
inconsistent across the discovery/token/resource-link read paths, the
implementation must fail closed for the public OAuth surface rather than emit
mixed origins. Returning an authorization response `iss` parameter is deferred
until RFC 9207 support is designed with matching metadata and tests.

### MCP protected-resource metadata

MCP connection and credential admission are exclusively owned by the
[L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md). This non-MCP OAuth design does not impose resource-profile,
Connected App, client-registration or MCP-scope prerequisites on that Worker.
The Worker must use Runtime's existing credential validity and current user
permissions; this is not permission to accept arbitrary token audiences or to
advertise an unimplemented OAuth onboarding flow.

### CORS

PKCE does not solve browser CORS, and per-Connected-App CORS is not in the MVP.

The blocker is preflight identity: an `OPTIONS` preflight for
`/services/oauth2/token` or `/services/data/*` does not carry the `client_id` or
bearer token needed to choose one Connected App's origin allowlist. The MVP
therefore supports browser-based clients only through same-origin app backends,
same-origin hosted tools, or non-browser/local clients where CORS is not part of
the token/API request path.

Cross-origin browser API calls are a future phase. That phase must define one
workable read path before adding `AllowedWebOrigins`:

- how preflight maps an `Origin` to a Connected App without trusting a caller
  supplied app id on an unauthenticated `OPTIONS` request
- whether the product instead uses deployment-level trusted origins
- how actual token/API requests re-check the same origin decision with the
  authenticated app/session
- how origin-denial metrics avoid logging tokens, codes, or verifiers

Do not implement a union of all Connected App origins as the fallback. That
would weaken origin isolation.

## 12. Threat model and controls

### Assets

| Asset | Why it matters |
| --- | --- |
| User password and password reset token | Full account takeover if exposed. External apps must not receive them. |
| Authorization code | Short-lived but redeemable for a session unless bound to PKCE and request context. |
| PKCE verifier | Proves the original client instance. Must never be stored or logged by NexusCRM. |
| Access token / session token | Bearer credential for API access. Scope, TTL, app binding, revocation, and audit matter. |
| Future refresh token | Long-lived credential. Requires hashing, rotation, family replay detection, and revocation. |
| Connected App secret | Confidential-client credential. Must not be shown after creation except through deliberate rotation UX. |
| Consent record | User authorization decision. Must be revocable and auditable. |
| CRM records and metadata | Actual protected resource. OAuth does not bypass existing permissions. |
| Audit logs | Evidence for security investigation and login-as accountability. Must be complete but not contain secrets. |
| Login-as actor/subject context | Prevents support impersonation from becoming invisible user activity. |

### Trust boundaries

| Boundary | Risk |
| --- | --- |
| User browser to NexusCRM authorize/login/consent | Phishing, CSRF, embedded user agents, login-state confusion. |
| NexusCRM to redirect URI | Open redirect, code leakage, redirect URI substitution. |
| Public client to token endpoint | Code interception, wrong verifier, app impersonation, future CORS abuse. |
| Token endpoint to session store | Incorrect app/user/scope binding, stale app policy, token leakage. |
| Bearer token to API routes | Token replay, overbroad scope, app/user deactivation not enforced. |
| Admin UI to Connected App records | Misconfigured redirect, scopes, consent policy, future origin policy, or secret exposure. |
| Logs/metrics/audit | Accidental token/code/verifier/password leakage. |
| Future MCP resource discovery | Owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md); its resource/audience and cross-host controls are not specified here. |
| Future login-as | Privilege abuse, missing audit, credential changes while impersonating. |

### Abuse cases

| Threat | Control | Gate |
| --- | --- | --- |
| Authorization code interception | S256 PKCE; one-time code; short TTL; code hash storage. | Wrong verifier and code reuse tests fail with `invalid_grant`. |
| Authorization code injection | Bind code to PKCE verifier, client, redirect URI, issuer, and scope. | Code from another client or redirect cannot be redeemed. |
| Open redirect / malicious redirect URI | Exact registered redirect matching; no redirect on invalid URI. | Invalid redirect test proves no request is sent to attacker URI. |
| Mix-up across authorization servers | Stable configured `issuer`; validate metadata issuer. RFC 9207 authorization-response `iss` is a future contract. | Metadata issuer tests pass; future `iss` support has matching metadata and redirect tests. |
| Public client impersonates confidential client | `ClientType` and `TokenEndpointAuthMethod`; public clients cannot use secrets; confidential clients still authenticate. | Public-with-secret and confidential-without-secret tests. |
| Token replay after theft | Short access-token TTL; app/user/session revocation; future DPoP/mTLS considered separately. | App deactivation invalidates existing app sessions. |
| Refresh token replay | No refresh token in MVP; future rotation and family revoke. | Metadata omits `offline_access` and `refresh_token` until rotation tests pass. |
| Consent phishing | Clear app identity, scopes, publisher/support fields, admin preapproval, user revocation. | Consent screen and revocation tests. |
| App access removed while tokens are live | Connected App access-policy mutations revoke affected user/app sessions immediately, unless a future implementation makes request-time app-access validation the single read path. | Removing app access or the entitlement behind `AdminApproved` invalidates existing delegated sessions. |
| Scope escalation | Public apps require explicit non-empty allowed scopes from the P1 allowlist (`external.records.read`) and explicit non-empty requested scopes from the same allowlist; empty public session scope is rejected instead of treated as full access. Route scope enforcement must use the explicit public route inventory, not blanket `api.read`. | `invalid_scope` tests for omitted/empty/unsupported scopes including `api.read`, `full`, `api`, `chatter_api`, and `openid`, plus route-access tests. |
| Cross-origin browser token theft | Cross-origin direct browser API calls are not in MVP; future CORS must define preflight app identity before `AllowedWebOrigins`. | MVP has no per-app CORS contract; future origin allow/deny tests are required. |
| CSRF on consent | Existing CSRF protection on consent POST; bind request snapshot to user session. | Consent POST without CSRF fails. |
| Token scanning through introspection | Do not advertise current introspection; future endpoint requires caller authorization and rate limits. | Discovery omits `introspection_endpoint`; future unauthorized introspection tests. |
| OIDC client expects ID Token | Do not advertise `openid` or `userinfo_endpoint` until full OIDC contract. | Discovery omits OIDC fields and scopes. |
| Secrets in logs | Redaction for token, code, verifier, password, client secret, refresh token. | Log-scrub unit tests or centralized redaction tests. |
| Sysadmin support impersonation abuse | Login-as is separate from OAuth; actor/subject audit; short TTL; reason; sensitive-surface blocks. | Login-as phase tests stamp actor and subject on audited writes. |

## 13. Token, session, and revocation model

### Access tokens

- Continue using NexusCRM session tokens for the first PKCE release.
- Access tokens are bearer tokens.
- Access tokens are bound in server-side session state to user id, Connected App
  id, scope, and grant type.
- Session validation continues to check user active state, app active state,
  session revocation, and DB-backed scope/app id.
- Connected App access-policy changes use the same revocation state: removing
  the grant or entitlement that authorized a user for an app immediately marks
  affected delegated sessions revoked.
- Token response uses `Cache-Control: no-store` and `Pragma: no-cache`.
- Token values, codes, verifiers, secrets, and refresh tokens are never logged.

### Revocation

User-facing delegated-app revocation is part of the public-client MVP even
though standards revocation discovery is not.

MVP revocation contract:

- user consent revocation immediately revokes all live
  `authorization_code` sessions for that `(user, ConnectedApp)` tuple
- admin revocation may target one user/app tuple or all sessions for an app
- Connected App deactivation revokes or invalidates all app-bound sessions
- Connected App access-policy changes, including removing direct app access
  grants or changing profile/permission-set entitlement behind
  `PermittedUsers=AdminApproved`, immediately revoke affected user/app sessions
- a revoked session fails on the next protected API request
- the revoke action records actor, target user, app, reason/source, and affected
  session count

No third-party public-app launch is shippable if the only user-visible "revoke
consent" action leaves the current access token alive until expiry.

The existing `/services/oauth2/revoke` may remain available, but do not
advertise `revocation_endpoint` until the standards revocation contract is
hardened and tested for both public and confidential clients.

Future revocation contract:

- invalid or already revoked tokens return 200 without enumeration
- confidential clients authenticate when revoking their own tokens
- public clients may revoke tokens issued to their app instance without a secret
  where the standard permits
- user/admin app revocation keeps the same immediate session-revocation
  semantics as the MVP user/admin controls

### Refresh tokens

No refresh tokens in the PKCE MVP.

When implemented:

- store only refresh-token hashes
- rotate on every use
- revoke the token family on replay
- bind family to user, app, scope, client type, and consent
- expose user/admin revocation UI
- add security audit events for issue, rotate, revoke, and replay detected
- advertise `offline_access` only after the above works

## 14. Consent and admin UX

### Admin creates a public app

Required fields:

- app name
- client type `public`
- redirect URI list
- explicit non-empty allowed scopes from the public allowlist
  (`external.records.read` in P1)
- consent policy
- app owner / publisher identity
- support URL or contact

Defaults:

- `RequirePkce=true`
- `TokenEndpointAuthMethod=none`
- `AllowedGrantTypes=["authorization_code"]`
- no refresh tokens
- exact redirect matching
- no cross-origin direct browser API calls in MVP

### User consent screen

Must display:

- app name
- publisher/owner
- requested scopes
- whether the app is trusted/admin-preapproved
- link to app support/details

Must provide:

- approve
- deny
- later user-visible revoke path

Consent is not required for first-party trusted flows only when the app is
explicitly marked trusted or admin-preapproved. Do not infer trust from
`localhost`, app name, redirect host, or the absence of a secret.

### User/admin revocation

Users should be able to revoke their own app consent. In the MVP, that action
must immediately revoke all live delegated sessions for the same
`(user, ConnectedApp)` tuple; blocking only future token exchange is not enough
for a third-party public-app launch.

Admins should be able to:

- deactivate a Connected App
- revoke all sessions for a Connected App
- revoke all sessions for one user/app tuple
- remove app access grants, which automatically revokes affected active
  delegated sessions
- inspect app usage and last-used timestamps
- force consent reprompt through `ConsentVersion`

## 15. MCP and agent-built app compatibility

MCP interoperability is a specialized design owned by
[L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
This document makes no normative claim about MCP scope names, discovery,
resource indicators, token audience, client registration, or agent onboarding.
Its generic PKCE, grant, Connected App, and revocation rules apply only where
the MCP design explicitly adopts them.

## 16. Password-auth retirement and login-as

The long-term target should be stronger than "do not use password grant for
external apps": NexusCRM should be able to retire password authentication as a
product login method entirely.

That retirement has three separate planes:

| Plane | Target |
| --- | --- |
| External app authorization | Authorization Code + PKCE; no password sharing; `password` absent from discovery. |
| End-user product login | Browser SSO, IdP-backed OIDC/SAML, passkeys, or another phishing-resistant login method. |
| Administrator support | Audited login-as session type, not password sharing or OAuth password grant. |

### Password grant

The current password grant is transition debt. It should remain absent from
RFC 8414 metadata and product-facing external-app docs. A separate identity
roadmap should decide whether to remove it, restrict it to bootstrap/admin
operations, or keep a compatibility exception for known Salesforce libraries.

### Login-as

System administrators sometimes need to see or debug the product as another
user. That need should be served by an explicit login-as substrate, not by
sharing passwords, minting password-grant tokens, or bypassing consent through
a hidden OAuth client.

Login-as design rules:

| Requirement | Design rule |
| --- | --- |
| Actor/subject separation | Store both the sysadmin actor user id and the target subject user id on session and audit context. |
| Explicit authority | Gate with a dedicated system permission or admin policy; do not infer from generic API scopes. |
| Reason and audit | Require a reason/ticket where policy requires it; write security audit entries for start, use, and end. |
| Short lifetime | Issue short-lived sessions with no refresh tokens. |
| Visible boundary | Make the UI/session state visibly impersonated to the actor; optionally notify target user/org admins. |
| Sensitive operations | Block or re-authenticate for password changes, connected-app consent, token management, MFA/passkey enrollment, and other credential/security surfaces. |
| API semantics | API calls run with the target user's permissions/RLS/FLS, while audit records preserve the sysadmin actor. |
| Exit behavior | Ending login-as returns the actor to their own session and closes subject context. |

This is not an OAuth grant type. It is an admin support feature layered on
NexusCRM session semantics, with stricter audit and shorter lifetime than
normal sessions.

## 17. Observability and audit

### Security audit events

Add or verify events for:

- Connected App created, updated, deactivated, deleted
- Connected App secret generated or rotated
- redirect URI changed
- app access grant added or removed
- authorization request started
- consent approved, denied, revoked
- authorization code issued
- token issued
- token exchange failed by reason category
- authorization code reuse detected
- wrong PKCE verifier
- token revoked
- app deactivation invalidated sessions
- future refresh token rotated, replay detected, family revoked
- future login-as started, ended, expired, and sensitive operation blocked

Audit records must carry correlation ids and actor/subject/app fields
where applicable. They must not carry token, code, verifier, password, refresh
token, or client secret values.

### Metrics

Track:

- authorize start/success/deny/failure counts by app and reason category
- token success/failure counts by grant type, app, and reason category
- PKCE missing/wrong/malformed counts
- invalid redirect attempts
- consent approval/denial/revocation
- future CORS allow/deny
- code reuse attempts
- token revocation counts
- app deactivation session invalidations
- future refresh replay detections
- future login-as sessions and blocked sensitive operations

High-cardinality values such as token, code, verifier, redirect query, and user
email do not belong in metrics labels.

## 18. Runway

> **Invariant:** existing confidential-client behavior and current
> Salesforce-compatible OAuth responses remain compatible unless a PR
> explicitly migrates a known client.

### P0 - Design acceptance and issue shaping

Land this document and confirm product/security decisions:

- access-token TTL for external public apps
- whether refresh tokens are MVP or follow-up
- MCP DCR/CIDMD and first-agent onboarding questions are owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md)
- whether localhost loopback port variance is MVP or follow-up
- confirm the configured canonical external base URL used for OAuth issuer,
  token response URLs, and resource identity links
- what scopes are safe to advertise in MVP metadata
- whether `client_credentials` discovery is a separate machine-client design
- whether password-auth retirement is tracked in this program or a separate
  identity program
- which permission/policy gates sysadmin login-as
- which sensitive surfaces are blocked inside impersonated sessions
- consent-screen copy, publisher verification rules, and the admin workflow for
  marking an app trusted/admin-preapproved

Gate: review agrees that discovery metadata is an allowlist of completed
contracts, that MVP excludes introspection and OIDC, and that no general
third-party public app can issue a code or token before consent or explicit
trusted/admin-preapproved policy is enforced.

### P1 - PKCE + consent authorization-code MVP

Implement the smallest shippable public-client cut. This phase is not a
partial PKCE-only launch: until every item in this phase is present, public
clients stay internal/trusted-only behind a feature flag and general
third-party public apps cannot issue delegated-user tokens.

- Connected App `ClientType`, `RequirePkce`, `TokenEndpointAuthMethod`
- Connected App `ConsentPolicy`, `ConsentVersion`, publisher display fields,
  and explicit trusted/admin-preapproved policy
- public-app scope guard: app allowed scopes and authorize-requested scopes must
  be explicit, non-empty, and inside the P1 public allowlist
  (`external.records.read`);
  empty public session scope is rejected
- generic scope-registry substrate plus source-backed CRM-pack
  `external.records.read` route inventory converged into
  `_System_OAuthCustomScope`, including the Salesforce-compatible identity URL
  and excluding `/services/oauth2/userinfo` and `/ws`
- one configured canonical external base URL for discovery, token
  `instance_url`, token `id`, and resource links
- `RedirectUris` migration from `CallbackUrl`
- `_System_OAuthConsent`
- consent-required decision in `/authorize` before code issuance
- consent-reuse rule keyed by user, app, revocation state, current
  `ConsentVersion`, and requested-scope subset
- consent screen and CSRF-protected approve/deny POST
- auth-code request snapshot binding the approved consent, app, user, redirect
  URI, scope, and PKCE challenge
- user/admin consent revocation that immediately revokes active user/app
  delegated sessions
- Connected App access-policy mutation hooks that immediately revoke affected
  delegated sessions when app access or `AdminApproved` entitlement changes
- OAuth code PKCE fields
- code hash storage migration
- PKCE helper functions and tests
- `/authorize` challenge validation
- `/token` verifier validation
- exact redirect matching
- app-bound `GrantType=authorization_code` sessions
- audit events for app, consent, code, and token lifecycle
- no refresh tokens
- no OIDC discovery
- no introspection discovery

Gate: a public Connected App can complete Authorization Code + PKCE with no
secret only after the user approves consent or the app is explicitly
trusted/admin-preapproved. Missing consent blocks code issuance; missing/wrong
verifier, code reuse, client mismatch, redirect mismatch, scope mismatch, and
revoked consent fail. Omitting `scope`, configuring an empty public-app scope
set, or producing an empty public session scope fails closed instead of
granting full access. Requesting or configuring public scopes outside
`external.records.read` fails closed, and prior consent is reused only when the
consent-reuse predicate matches.

### P2 - Admin UX and operational hardening

Improve management and operations after the security boundary exists:

- Connected App setup UI for public vs confidential apps
- redirect URI list UI
- one-time display or hashing plan for confidential app secrets
- app inventory showing client type, consent policy, active sessions, and last
  token activity
- user consent inventory and revocation UX
- admin warnings for broad scopes, unverified publishers, and loopback/native
  redirect policy
- support/security runbook for disabling an app and investigating token abuse

Gate: admins and users can find, audit, and revoke external app access without
advertising any new OAuth/OIDC token contract.

### P3 - Authorization server discovery

Add standards metadata for the completed public-client contract:

- authorization server metadata with only completed MVP fields
- metadata contract tests proving excluded fields are absent

Gate: a standards-based **non-MCP** client can discover endpoints and S256
support without discovering password, OIDC, introspection, refresh tokens, DCR,
or cross-origin CORS. MCP protected-resource discovery is owned by
[L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).

### P4 - Revocation hardening and refresh-token rotation

Implement only when durable long-lived connector use cases are approved:

- hardened RFC 7009 revocation contract
- `_System_OAuthRefreshToken`
- refresh-token hash storage
- rotation on every use
- token-family replay detection
- user/admin revocation by app
- `offline_access` or equivalent scope
- discovery update for `refresh_token` and revocation only after tests pass

Gate: reuse of an old refresh token revokes the family, and metadata advertises
refresh/revocation only after the contract is implemented.

### P5 - Optional standards expansions

Add only as separate accepted designs:

- RFC 7662 introspection with protected-resource caller authorization
- OpenID Connect provider behavior with ID Tokens, JWKS, claims, nonce, and
  OIDC discovery
- Dynamic Client Registration
- Client ID Metadata Documents
- Device Authorization Grant for headless CLIs
- MCP resource indicators and protected-resource metadata, which are 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 after preflight app-identification exists
- DPoP or mTLS sender-constrained tokens
- RFC 9207 authorization response issuer parameter

Gate: each expansion has its own endpoint contract, data model, abuse controls,
and metadata tests before advertisement.

### P6 - Password-auth retirement and login-as

Implement only after the organization has a replacement login path for normal
users:

- retire password login and password grant from product documentation
- keep `password` absent from OAuth discovery metadata
- add sysadmin login-as as a distinct audited session type
- stamp actor and subject user ids on session/audit context
- block credential, MFA/passkey, connected-app consent, and token-management
  surfaces inside impersonated sessions
- add user/admin notifications if product policy requires them

Gate: sysadmins can support users without knowing their passwords or minting
password-grant tokens, and every impersonated API write is auditable as
actor-admin-on-behalf-of-subject-user.

## 19. Verification plan

### Metadata contract tests

- authorization server metadata advertises `S256`
- metadata advertises `authorization_code`
- metadata advertises only `none` and `client_secret_post` token endpoint auth
  methods until Basic-auth handling is designed
- metadata does not advertise `password`
- metadata does not advertise `refresh_token`
- metadata does not advertise `offline_access`
- metadata does not advertise `openid`
- metadata does not publish `userinfo_endpoint`
- metadata does not publish `introspection_endpoint`
- metadata does not publish `registration_endpoint`
- non-MCP authorization-server metadata does not publish product scopes; MCP
  protected-resource scopes and resource-indicator tests are owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md)

### Backend unit and integration tests

- public client completes Authorization Code + PKCE without a secret
- confidential client still requires a valid secret when configured
- public client supplying `client_secret` is rejected
- missing PKCE on a public app fails at authorize
- unsupported `code_challenge_method` fails at authorize
- malformed `code_challenge` fails at authorize
- public app with empty allowed scopes fails closed
- public app configuration rejects unsupported public scopes such as `api.read`,
  `full`, `api`, `chatter_api`, `openid`, custom scopes, and unknown scopes
- public authorize request with omitted or empty `scope` fails with
  `invalid_scope`
- public authorize request for unsupported scopes fails with `invalid_scope`
  even if the app was misconfigured to contain that scope
- public auth code or session with empty granted scope is rejected instead of
  interpreted as full API access
- malformed verifier fails at token
- wrong verifier fails with `invalid_grant`
- authorization code cannot be reused
- expired authorization code cannot be redeemed
- code issued to one client cannot be redeemed by another client
- code issued to one redirect URI cannot be redeemed with another redirect URI
- invalid redirect URI is not used for error redirects
- scope request outside app scopes fails with `invalid_scope`
- public route coverage comes from a generic scope registry / CRM pack metadata,
  not hardcoded Salesforce route branches in L0 OAuth code
- `external.records.read` can call only the explicit P1 route inventory and cannot
  call `/services/oauth2/userinfo`, `/ws`, `/cometd/*`, `/connect/*`,
  `/tooling/*`, `/ui-api/*`, `/analytics/*`, or writes
- `/services/data/:version` is filtered for `external.records.read` and omits
  out-of-scope links such as `connect`, `tooling`, `analytics`, `ui-api`,
  `composite`, `metadata`, `recent`, and write surfaces
- `/id/:orgId/:userId` and `/services/data/:version/id/:orgId/:userId` are
  filtered for `external.records.read` and omit SOAP, profile, recent, Connect,
  Tooling, Analytics, UI API, and UserInfo links
- token response `id` uses the canonical external base URL and points to
  `/id/:orgId/:userId`
- an `external.records.read` token can dereference its token response `id` URL
- discovery issuer, discovery endpoints, token `instance_url`, token `id`, and
  `/services/data/:version` `identity` link all share the configured canonical
  external base URL
- missing, malformed, or inconsistent canonical external base URL fails closed
  for public OAuth discovery/token/resource-link generation
- app deactivation invalidates later API calls for existing app-bound sessions
- user deactivation invalidates later API calls
- consent revocation immediately invalidates existing user/app delegated
  sessions, blocks new token exchange, and blocks refresh-token use when
  refresh tokens are later introduced
- existing consent skips prompt only for same user, same app, unrevoked current
  `ConsentVersion`, and requested scope subset of stored consent scope
- scope expansion and `ConsentVersion` bump force a new consent prompt
- removing app access or the entitlement behind `AdminApproved` immediately
  invalidates existing delegated sessions for affected users/apps
- scoped token can call matching routes and fails on insufficient scope
- token responses include no-store headers
- token, code, verifier, password, and client secret are redacted from logs

### Frontend/admin tests

- creating a public app does not require or display a client secret
- creating a confidential app still supports secret generation
- callback URL validation errors are visible
- consent screen displays app name, publisher, and requested scopes
- denial returns a safe OAuth error to the trusted redirect URI
- user can revoke consent
- admin can deactivate app and see sessions invalidated

### Browser smoke

1. Create a public Connected App with redirect URI
   `http://127.0.0.1:8080/callback`.
2. Generate a PKCE verifier/challenge in a local script.
3. Open `/services/oauth2/authorize` in a browser.
4. Approve consent.
5. Exchange code with `code_verifier` and no secret.
6. Call `/services/data/v63.0/sobjects` with the returned bearer token.
7. Verify wrong verifier, code reuse, insufficient scope, and app deactivation
   failures.

### Security review checklist

- threat model reviewed with security owner
- discovery metadata reviewed as public contract
- redirect matching reviewed for open redirect and native loopback policy
- consent UX reviewed for app identity and scope clarity
- audit events reviewed for incident investigation
- log redaction reviewed with test evidence
- cross-origin browser CORS remains explicitly deferred until preflight
  app-identification is designed
- refresh-token phase blocked until rotation tests exist
- OIDC phase blocked until complete OIDC design exists
- login-as phase blocked until actor/subject audit and sensitive-surface block
  tests exist

## 20. Backout and compatibility

Backout must preserve existing confidential-client behavior:

- Existing Connected Apps default to `confidential`.
- Existing `CallbackUrl` continues to work until `RedirectUris` migration is
  complete.
- Existing password grant remains server-supported only as transition debt and
  stays absent from discovery.
- Existing client credentials behavior remains server-supported only under its
  current compatibility contract and stays out of PKCE MVP discovery unless a
  separate machine-client metadata decision accepts it.
- If PKCE rollout fails, disable creation of new public apps and leave existing
  confidential apps untouched.
- If discovery rollout fails, remove the metadata route or fields rather than
  expanding metadata to unimplemented surfaces.
- If consent rollout fails, public third-party apps fail closed before token
  issuance.

## 21. Open questions

- What is the first supported **non-MCP** external-app story: browser dashboard,
  local CLI, or both?
- Should access-token TTL differ for public external apps?
- Should refresh-token rotation be part of the first shipping PKCE release, or
  should users re-authenticate when access tokens expire?
- Which scope expansion should follow the P1 `external.records.read`
  public-app allowlist: CRM-pack `crm.data.read`, write access, Connect API
  access, additional read routes, custom route scopes, or OIDC?
- Should `client_credentials` receive a separate discovery design, or remain
  unadvertised compatibility behavior?
- MCP client-registration questions are owned by
  [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
- Should localhost loopback redirect port variance ship immediately, or should
  the first release require exact localhost redirect URIs?
- MCP resource topology is owned by [L1 MCP Worker contract](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/49e9998af867edfc3f5715eee2bfc8e8e0b8c2f7/docs/design/mcp-l1-worker.md).
- Is password authentication retirement part of this OAuth/PKCE program, or a
  separate identity-roadmap program?
- What exact permission, approval policy, and notification rules should govern
  sysadmin login-as?

## References

### Design and secure planning

- [Software Engineering at Google - Documentation](https://abseil.io/resources/swe-book/html/ch10.html)
- [Google Documentation Best Practices](https://google.github.io/styleguide/docguide/best_practices.html)
- [Google Research: Improving Design Reviews at Google](https://research.google/pubs/improving-design-reviews-at-google/)
- [NIST SP 800-218: Secure Software Development Framework](https://csrc.nist.gov/pubs/sp/800/218/final)
- [OWASP Threat Modeling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Threat_Modeling_Cheat_Sheet.html)
- [OWASP Application Security Verification Standard](https://owasp.org/www-project-application-security-verification-standard/)
- [OWASP OAuth 2.0 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)

### OAuth, OIDC, and MCP

- [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://www.rfc-editor.org/rfc/rfc7636)
- [RFC 6749: OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749)
- [RFC 7009: OAuth 2.0 Token Revocation](https://www.rfc-editor.org/rfc/rfc7009)
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662)
- [RFC 8252: OAuth 2.0 for Native Apps](https://www.rfc-editor.org/rfc/rfc8252)
- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://www.rfc-editor.org/rfc/rfc8414)
- [RFC 8707: Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707)
- [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207)
- [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession](https://www.rfc-editor.org/rfc/rfc9449)
- [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700)
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0-18.html)
- [Model Context Protocol Authorization Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization)
- [Claude connector authentication documentation](https://claude.com/docs/connectors/building/authentication)
- [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp)

### Salesforce compatibility

- [Salesforce: OAuth 2.0 Web Server Flow for Web App Integration](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_web_server_flow.htm&type=5)
- [Salesforce: Proof Key for Code Exchange Extension](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_pkce.htm&type=5)
