Every PayCargo cartridge that integrates a SaaS system of record (Salesforce, GitHub, GitBook, Monday) will need OAuth. If each cartridge implements its own OAuth flow, four things go wrong:
The Forge model already says cartridges declare; the platform enforces. OAuth is the same pattern. The Console owns the OAuth flow. Cartridges declare which providers they consume, and the foundation hands them a current valid token.
Distinct on purpose. Mixing them is the root cause of every OAuth abstraction that ages badly.
| Concept | What it is | Identity | Storage |
|---|---|---|---|
| Provider | A registered OAuth target. Static configuration: authorize URL, token URL, scopes, PKCE flag, callback path, where the client credentials live. | provider_id (e.g., salesforce-sandbox) |
SSM Parameter Store under /forge/oauth/providers/<id> |
| Connection | One authenticated linkage between Forge and a provider, owned by a specific PayCargo user. Has tokens. | connection_id = <provider_id>:<username> |
DDB row for metadata + Secrets Manager entry for tokens (KMS-encrypted) |
| Consumer | A cartridge that needs a current valid access token for a connection to make API calls. | The cartridge identity (its IAM role) + the user from the JWT | N/A (reads via IAM-scoped Secrets Manager access) |
Adding a provider is one SSM parameter. Adding a connection is one OAuth flow run by an end user. Adding a consumer is zero work for the OAuth layer; the cartridge calls getOAuthConnection('salesforce-sandbox') and it just works.
Tempting first instinct: one "PayCargo Salesforce connection" the whole platform shares. Wrong, because every Salesforce API call would look like it came from a single service account, defeating Salesforce's audit log on our side. The Salesforce admins would have one row for everything. Compliance fails.
If every cartridge has its own Salesforce connection, every cartridge has its own login flow, its own refresh token, its own Connected App. The auth surface multiplies with N cartridges. Operators connect Salesforce five times, once per cartridge, with five sets of consent screens. Every cartridge's IAM role needs Salesforce client secret read access. The refresh scheduler iterates N times more.
And the operational insight that closes the case: we cannot escape a role boundary anyway. IAM permission boundaries already constrain what each cartridge's role can do. Layering an application-level "this connection is only for cartridge X" check on top of an IAM boundary that already enforces "this role can only do Y" is redundant complexity with no marginal safety. The role boundary is the trap door; application-level scoping is theater.
One connection per user per provider. mtang connects Salesforce Sandbox once in Setup. Every cartridge invocation made by mtang resolves getOAuthConnection('salesforce-sandbox') to mtang's token. jdoe needs to connect separately to make Salesforce calls under her own identity. Salesforce's audit log shows the right user every time.
| Property | Why it matters |
|---|---|
| One refresh path per user per provider | The scheduler sweeps M users × P providers connections, deterministic. |
| Setup UI shows only your own connections | Each user manages their own. No "which connection am I editing" confusion. |
| Audit trail aligns user identity end-to-end | The Cognito JWT username matches the Salesforce username on the API call. Audit reviewer sees one user, one trail. |
| Revocation is bounded | If jdoe leaves PayCargo, we revoke her one Salesforce connection. mtang's connection is untouched. |
A provider is a JSON manifest under /forge/oauth/providers/<provider_id> in SSM Parameter Store. Adding a provider means adding a Terraform aws_ssm_parameter resource. The OAuth flow Lambda reads the registry at runtime, never hard-codes provider URLs.
{
"id": "salesforce-sandbox",
"display_name": "Salesforce Sandbox",
"icon": "fa-solid fa-cloud-arrow-up",
"authorize_url": "https://test.salesforce.com/services/oauth2/authorize",
"token_url": "https://test.salesforce.com/services/oauth2/token",
"revoke_url": "https://test.salesforce.com/services/oauth2/revoke",
"userinfo_url": "https://test.salesforce.com/services/oauth2/userinfo",
"scopes_requested": ["api", "refresh_token", "offline_access"],
"use_pkce": true,
"callback_url": "https://console.paycargo.org/api/oauth/callback/salesforce-sandbox",
"client_id_secret_arn": "arn:aws:secretsmanager:us-east-1:959228203854:secret:/forge/oauth/clients/salesforce-sandbox",
"metadata_fields": ["instance_url", "id"],
"refresh_window_min": 60,
"adapter": "salesforce",
"capabilities": [
{ "name": "soql", "scope_required": "api", "summary": "Run a SOQL query, returns records" },
{ "name": "sobject.get", "scope_required": "api", "summary": "Read one sobject by id" },
{ "name": "sobject.create", "scope_required": "api", "summary": "Create one sobject" },
{ "name": "sobject.update", "scope_required": "api", "summary": "Update one sobject by id" },
{ "name": "sobject.delete", "scope_required": "api", "summary": "Delete one sobject by id" },
{ "name": "describe", "scope_required": "api", "summary": "Get sobject metadata" },
{ "name": "bulk.query", "scope_required": "api", "summary": "Bulk API SOQL for large result sets" }
]
}
metadata_fields is the abstraction's escape hatch for provider-specific response fields. Salesforce returns instance_url in the token response; this manifest tells the OAuth flow to persist it as connection metadata. GitHub's token response has no instance URL; GitHub's manifest omits the field. No code change.
refresh_window_min: 60 tells the scheduler to refresh anything expiring in the next 60 minutes. Per-provider tunable.
adapter names which file in shared/modules/oauth/adapters/ implements this provider's capability surface. capabilities is the data-side declaration of what the adapter exposes. See section 6 below for what these do.
| Provider | v1 | PKCE | Notes |
|---|---|---|---|
salesforce-sandbox | Yes | Required | test.salesforce.com |
salesforce-production | Yes | Required | login.salesforce.com |
github | Roadmap | Supported | Per-user PAT-style flow |
gitbook | Roadmap | Supported | Spaces API |
monday | Roadmap | Supported | GraphQL API |
The egress allowlist (Step 3) already lets these five domains out of the VPC. OAuth is the layer above that enforcement.
New top-level tab in the Console app. Available to every authenticated user (not admin-gated): each user manages their own connections.
| Status | Color | Meaning |
|---|---|---|
| OK | Green | Last refresh succeeded; token valid; cartridges can use it. |
| Refreshing | Yellow | Scheduler is currently rotating this token. Brief state, normally invisible. |
| Needs re-auth | Amber | Refresh failed 3 times in a row. User must click Connect again. SES email sent on first transition into this state. |
| Revoked | Red | Disconnected, either by user click or by provider-side revocation. Cartridges using it fail until reconnected. |
The token is necessary but not sufficient. A cartridge that receives a Salesforce access token still needs to know what Salesforce can do, how to call it, and which response fields matter. If every cartridge author re-derives that from Salesforce's docs, the abstraction has failed.
Without an adapter, the cartridge becomes a Salesforce expert. It hardcodes /services/data/v59.0/query. It builds query strings. It parses Salesforce's slightly-non-standard JSON. The next cartridge integrating Salesforce repeats every line of that. When Salesforce bumps its API version, the cartridges break in N places, not one.
With an adapter, the cartridge calls sf.soql('SELECT Id FROM Account'). The adapter owns the version, the URL shape, the error decoding, the pagination, the rate-limit handling. Cartridges treat Salesforce as a verb library, not as an HTTP server.
"capabilities": [
{ "name": "soql", "scope_required": "api", "summary": "Run a SOQL query, returns records" },
{ "name": "sobject.get", "scope_required": "api", "summary": "Read one sobject by id" },
...
]
This serves two audiences. Operators see capability availability in the Setup tab (which capabilities a given connection actually enables, given what scopes Salesforce granted). Cartridge authors see a discoverable list of verbs without reading Salesforce docs.
shared/modules/oauth/adapters/
salesforce.js # implements every capability for both salesforce-sandbox and salesforce-production
github.js # implements GitHub's capability set
gitbook.js # implements GitBook's capability set
monday.js # implements Monday's capability set
_index.js # adapter registry: maps adapter name from manifest to the module
Each adapter is one file, zero npm, fetch + JSON. The adapter is constructed from a connection and a manifest, returns an object whose methods are the named capabilities. Adapters never deal with tokens directly; they receive a thin connection handle that fetches the current valid token from Secrets Manager on demand.
This is the subtle part. The connection record stores scopes_granted (the actual scopes Salesforce returned in the token response, which may be a subset of scopes_requested). The adapter factory only attaches methods whose scope_required is in the granted set. Methods that need an ungranted scope still exist on the adapter, but they throw a clear error rather than calling the API and getting a 403.
// Inside salesforce.js (paraphrased)
export function buildAdapter(connection, manifest) {
const granted = new Set(connection.scopes_granted);
const adapter = {};
for (const cap of manifest.capabilities) {
if (granted.has(cap.scope_required)) {
adapter[cap.name] = wireImplementation(cap.name, connection);
} else {
adapter[cap.name] = () => {
throw new Error(
`Capability ${cap.name} requires scope ${cap.scope_required}, ` +
`which was not granted on this connection. Reconnect from Setup with broader scopes.`
);
};
}
}
return adapter;
}
Calling sf.bulk.query(...) on a connection that did not get the api scope fails fast in the Lambda with a debuggable message, instead of round-tripping to Salesforce for a 403.
The connection row in the Setup table gains a Capabilities column showing what is actually available on each connection.
| Provider | Connected As | Scopes Granted | Capabilities Available | Status |
|---|---|---|---|---|
| Salesforce Sandbox | mtang@pc.demo.full | api refresh_token offline_access |
soql, sobject.{get,create,update,delete}, describe, bulk.query | OK |
| Salesforce Sandbox | jdoe@pc.demo.full | id |
(none) — reconnect with broader scopes | Limited |
Opening shared/modules/oauth/adapters/salesforce.js in the editor shows the entire Salesforce surface as one file with JSdoc on every method. The cartridge author never looks up Salesforce API version numbers, endpoint paths, or quirks of the JSON shape.
/**
* Run a SOQL query against this Salesforce connection.
*
* @param {string} soql A SOQL query string
* @param {object} [opts]
* @param {number} [opts.limit] Max records (default: no client-side limit)
* @returns {Promise<{ totalSize: number, done: boolean, records: object[] }>}
*
* Example:
* const r = await sf.soql('SELECT Id, Name FROM Account LIMIT 10');
* for (const row of r.records) console.log(row.Name);
*/
async function soql(soql, opts = {}) { ... }
| Provider | Capabilities the adapter ships | Approx adapter size |
|---|---|---|
| Salesforce (sandbox + production) | soql, sobject.get/create/update/delete, describe, bulk.query, userinfo | ~200 lines |
| GitHub | issues.{get,list,create,update}, labels.{add,remove}, repos.getContent, pulls.{get,list,create}, actions.dispatch | ~250 lines |
| GitBook | spaces.list, spaces.getContent, pages.{get,create,update} | ~120 lines |
| Monday | boards.{list,get}, items.{list,get,create,update}, columns.update | ~150 lines (GraphQL, single endpoint) |
Every capability call emits a structured oauth-capability-complete log line. Shape: { msg, user, provider_id, capability, durationMs, success, error? }. The obs Cartridges tab joins these by user and by provider so an audit reviewer can answer "what did mtang do on Salesforce yesterday" in one query.
An adapter is a thin verb wrapper. It does not model Salesforce's full object tree. It does not generate types. It does not parse SOQL or validate it. It passes the SOQL string through to Salesforce. If Salesforce supports it, it works. If not, the cartridge gets Salesforce's error back. We are not in the business of being a Salesforce SDK; we are in the business of giving cartridges a clean verb surface so that token plumbing and capability discovery are not their problem.
This is the right altitude for v1 and probably forever. If a cartridge does something exotic with Salesforce, it can use sf.raw(method, path, body) as the escape hatch, which every adapter provides.
Tokens expire. Some pattern keeps them current. Four candidates, ranked.
| Pattern | How it works | Verdict |
|---|---|---|
| Scheduler sweep (chosen) | EventBridge Scheduler fires every 10 min. Lambda iterates all connections, refreshes any expiring within refresh_window_min. Failures bumped onto retry; 3 consecutive failures flips status to needs-reauth. |
Chosen. Token always fresh in Secrets Manager. Refresh failures isolated from cartridge request path. Reuses Step 6.k EventBridge + DLQ + watchdog plumbing. |
| Lazy refresh on read | Cartridge reads token from Secrets Manager. If expired, calls provider's refresh endpoint inline, writes new token back, retries. | Rejected. Refresh failures hit user-facing latency. Concurrent cartridge invocations near expiry can race on the refresh and the loser sees a brief 401. |
| Reactive on 401 | Cartridge uses cached token. On Salesforce 401, refreshes, retries. | Rejected. Same race condition. Adds 200-500ms to the unlucky first call after expiry. |
| Provider webhook | Provider notifies us when token is about to expire. | Rejected. Almost no providers actually support this. Doesn't generalize. |
Same primitives as the obs narrative sweeper: wrapScheduledHandler writes start + end run records to DDB; the Step 6.k watchdog fires SES alerts if the sweeper itself misses its SLA.
Cost: at 10-minute cadence, the sweeper invokes 4,320 times per month. Lambda billing for a 5-second function call at 128 MB is roughly $0.10 / month. Negligible.
Salesforce drives the abstraction shape because it carries every OAuth wrinkle. Once it works, every other provider is a subset.
Pre-requisite, done by a Salesforce admin in Salesforce Setup → App Manager:
https://console.paycargo.org/api/oauth/callback/salesforce-sandbox (plus the production callback for the prod Connected App).api, refresh_token, offline_access./forge/oauth/clients/salesforce-sandbox.Two Connected Apps: one in the Salesforce sandbox instance, one in production. Two provider manifests in SSM. Two distinct client_id pairs. No environment toggling in code.
Salesforce's token response carries instance_url, which differs per Salesforce org. Cartridges making API calls need it ({instance_url}/services/data/...). The provider manifest's metadata_fields: ["instance_url", "id"] tells the OAuth flow to persist it as connection metadata. The cartridge consumption helper returns it alongside the access token:
const sf = await getOAuthConnection('salesforce-sandbox');
// {
// accessToken: 'sf-eyJ...',
// metadata: {
// instance_url: 'https://paycargodev.my.salesforce.com',
// id: 'https://test.salesforce.com/id/00DXX.../005XX...'
// },
// expiresAt: '2026-06-10T22:17:00Z'
// }
The Console Lambda has the same no-npm rule as every cartridge Lambda. Node 20's built-ins cover everything.
| Need | Built-in |
|---|---|
| HTTPS to authorize / token / revoke | fetch() |
| PKCE code_verifier | crypto.randomBytes(32).toString('base64url') |
| PKCE code_challenge | crypto.createHash('sha256').update(verifier).digest('base64url') |
| State parameter (CSRF) | crypto.randomBytes(32).toString('hex') |
| Form-encoded token request body | URLSearchParams |
| Token storage | @aws-sdk/client-secrets-manager (Lambda runtime) |
| Metadata storage | @aws-sdk/client-dynamodb + lib-dynamodb (Lambda runtime) |
| SSM provider manifests | @aws-sdk/client-ssm (Lambda runtime) |
Helpers live under shared/modules/oauth/, staged into lambda/_shared/oauth/ at apply time via the same local_file + archive_file pattern we established in shared/modules/.
shared/modules/oauth/
README.md # zero-npm doctrine + provider manifest shape + capability convention
flow.js # createOAuthFlow({provider, userJwt}): { authorize_url, complete(code, state) }
pkce.js # generatePkce() and verify helpers
store.js # readConnection / writeConnection / deleteConnection (DDB + Secrets Manager)
refresh.js # refreshConnection({connection_id}): rotates tokens
client.js # getOAuthClient(provider_id, jwt): returns a runtime adapter
adapters/
_index.js # adapter registry (manifest.adapter -> module)
salesforce.js # implements Salesforce capability set; used by both sandbox and production manifests
github.js # roadmap
gitbook.js # roadmap
monday.js # roadmap
| Layer | What | Path |
|---|---|---|
| Foundation (Terraform) | OAuth module: SSM parameter prefix, Secrets Manager namespace, KMS grants, DDB key shape, refresh sweeper Lambda + schedule | substrate/modules/platform/oauth/ |
| Foundation (data) | Provider manifests as SSM parameters | /forge/oauth/providers/salesforce-sandbox/forge/oauth/providers/salesforce-production |
| Foundation (secrets) | OAuth client credentials per provider | Secrets Manager: /forge/oauth/clients/<provider_id> |
| Shared modules | Zero-npm OAuth helpers (flow, PKCE, store, refresh, client) | shared/modules/oauth/ |
| Console Lambda | OAuth API routes; consumes shared/modules/oauth/ |
console/lambda/handler.js dispatches to console/lambda/oauth.js |
| Console frontend | Setup tab (provider cards, connections table) | console/frontend/setup.js + setup section in index.html |
| Cartridges | Token consumption (via shared helper) | Each cartridge: lambda/handler.js imports from ./_shared/oauth/client.js |
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/oauth/providers | JWT | List registered providers from SSM |
| GET | /api/oauth/connections | JWT | List the caller's connections (DDB query pk=oauth-connection, sk=<username>) |
| POST | /api/oauth/connect/:provider | JWT | Start flow: generate PKCE + state, return authorize_url |
| GET | /api/oauth/callback/:provider | None (state-validated) | Exchange code for tokens, persist, redirect back to Setup |
| POST | /api/oauth/disconnect/:connection | JWT (must match owner) | Revoke + delete |
| POST | /internal/oauth/sweep | EventBridge principal | Refresh sweeper (called by EventBridge Scheduler) |
// In any cartridge's lambda/handler.js
import { getOAuthClient } from './_shared/oauth/client.js';
// jwtPayload is from the existing JWT verify path
const sf = await getOAuthClient('salesforce-sandbox', jwtPayload.username);
if (!sf) {
return jsonResponse(409, {
error: 'No Salesforce connection',
detail: 'Open the Console Setup tab and click Connect on Salesforce Sandbox.',
setupUrl: 'https://console.paycargo.org/setup',
});
}
// Adapter exposes Salesforce capabilities as typed verbs.
// No fetch, no URLs, no Authorization header, no refresh handling.
const accounts = await sf.soql('SELECT Id, Name FROM Account LIMIT 10');
for (const row of accounts.records) console.log(row.Name);
const opp = await sf.sobject.create('Opportunity', {
Name: 'Q3 PayCargo Pilot',
CloseDate: '2026-09-30',
StageName: 'Prospecting',
});
getOAuthClient reads the connection row, fetches the provider manifest, loads the named adapter, and constructs a runtime object whose methods are the capabilities granted by the connection's scopes. The cartridge calls verbs, never raw HTTP.
This is the abstraction's whole point. Cartridge code is small, declarative, and never changes when we swap providers or add new ones. The adapter and the manifest move; the cartridge does not.
Every adapter ships a raw(method, path, body?) capability for the cases where the cartridge needs something the adapter does not yet expose. sf.raw('GET', '/services/data/v59.0/limits'). Same auth plumbing, same audit log. When a cartridge uses the escape hatch more than twice, the next adapter PR adds that capability natively.
| Concern | Handling |
|---|---|
| Tokens at rest | KMS-encrypted via the agent CMK in Secrets Manager. Read events flow into CloudTrail data events. |
| Tokens in transit | HTTPS everywhere. CloudFront → API Gateway HTTP API → Lambda already enforced. |
| OAuth state CSRF | Random state per flow, stored short-lived in DDB under pk=oauth-state, sk=<state> with 10-minute TTL. Callback rejects unknown or expired state. |
| OAuth code interception | PKCE on every provider that supports it (Salesforce: required). |
| Setup tab access | JWT-gated for any authenticated user; each user only sees their own connections. No admin gate (per-user model). |
| Disconnect | Calls provider's revoke endpoint first, then deletes secret + DDB row. Failed revoke still completes local deletion (provider eventually expires the token). |
| Capability gate | The adapter only attaches methods whose scope_required is in connection.scopes_granted. Ungranted methods throw with a clear message instead of round-tripping to the provider for a 403. |
| Audit | Every connect / refresh / disconnect / token-fetch / capability-call emits a structured oauth-<event>-complete log line (using shared/modules/log.js) for the obs Cartridges tab to surface. Capability calls log provider_id + capability + durationMs + success. |
| Refresh failures | After 3 consecutive failed refreshes, connection flips to needs-reauth and SES-emails the owner. The user reconnects from the Setup tab. |
| Egress allowlist alignment | The five providers (Salesforce sandbox + prod, GitHub, GitBook, Monday) align with the network egress allowlist from Step 3. OAuth does not add new external destinations; it authenticates against ones already permitted. |
| In v1 | Deferred |
|---|---|
| Provider abstraction (data-driven, SSM-backed) | GitHub provider (next) |
| Salesforce Sandbox + Production providers | GitBook provider |
| Setup tab UI with Connect, Disconnect, Re-authenticate | Monday provider |
| Background refresh sweeper (10-min cadence) | Per-connection rate-limit configuration |
| Token storage in Secrets Manager (KMS-encrypted) | Multi-account-per-user Salesforce (today: one Salesforce org per user per environment) |
| Cartridge consumption helper | OAuth scope renegotiation UI (today: declared in provider manifest only) |
| Audit log surfacing in obs Cartridges tab | SAML-based providers (different protocol, separate doctrine) |
| Refresh-failure SES email | OAuth 1.0 providers (none on roadmap) |
Five small PRs, each independently apply-able, each with a clear rollback.
flow.js, pkce.js, store.js, refresh.js, client.js, and adapters/salesforce.js with the seven Salesforce capabilities and the raw escape hatch. Smoke-tested locally against a unit harness; integration smoke deferred to PR 5. Same shape as the JWT verify migration that landed earlier this week.
sales/quoting cartridge) imports getOAuthConnection and makes its first Salesforce call. End-to-end smoke test of the whole stack.
Custom domain prerequisite: PR 4's Setup tab and PR 5's cartridge token consumption work fine on the CloudFront URLs, but the Connected App callback URLs (configured in Salesforce admin) reference console.paycargo.org. That domain needs to be live before PR 3 lands. See the DNS / cert wire-up PR currently in flight.
https://console.paycargo.org/api/oauth/callback/<provider_id>. We finalize the custom domain in the cert/Cognito wire-up PR (depends on DNS admin records being in place). Until then, the OAuth flow returns to the CloudFront URL and Salesforce rejects the callback. Sequence: DNS records propagate → custom domain wire-up applies → Salesforce Connected App created with the final callback → PR 3 ships.
| Decision | Locked answer |
|---|---|
| Provider registry mechanism | SSM Parameter Store |
| Connection storage split | DDB metadata + Secrets Manager tokens |
| Token freshness | Background scheduler sweep (10-min cadence) |
| Setup tab name | Setup (available to all authenticated users for their own connections) |
| Connection scope | Per-user, all-cartridge. Per-cartridge rejected as a trap (IAM role boundaries already enforce scope; application-level repeat is theater). |
| Salesforce Connected App | Manual one-time admin task in Salesforce. Confirmed for staging (sandbox + production) at the start. |
| Provider roadmap | v1: Salesforce Sandbox + Production. Roadmap: GitHub, GitBook, Monday. (HubSpot removed.) |
| Capability surface | Two-layer abstraction. Layer 1 (token plumbing) covered in sections 1-5 and 7-9. Layer 2 (capability adapters) covered in section 6. Cartridges program against adapter verbs, never raw HTTP. Capabilities declared in manifest, implemented in shared/modules/oauth/adapters/<name>.js. Scope-to-capability gate enforced at adapter construction time. |