ChatGPT and Codex subscription pairing
Standard Red Notes can pair one or more ChatGPT/Codex subscription credentials to named assistant backend slots. Pairing is an implemented administrator capability: the gateway creates PKCE material, exchanges the OAuth code, stores the resulting credential encrypted, refreshes it when safe, and supplies it only to the server-side assistant provider.
Configure the gateway
Supported installs configure this automatically. Fresh runs and ordinary reruns
of scripts/setup.sh or scripts/setup.ps1 generate the internal encryption key
once, persist it, and preserve it on every later run. The LXC installer does the
same in its root-only secret store. No administrator setting or pasted key is
required. For an older setup-generated environment, rerun the matching setup
script normally:
./scripts/setup.sh
./scripts/setup.ps1
Before writing anything, setup validates Compose and inspects the current server
container or its mounted gateway data directory. It refuses to generate a key if
an encrypted pairing file exists, if a stopped container or custom token path
prevents reliable inspection, or if the current .env contains a malformed or
ambiguous assignment. On success it creates a timestamped .env.bak.*, writes
one 64-hex key atomically, and validates Compose again. If pairing data already
exists, setup fails closed: restore its original key because a new key cannot
decrypt it.
Run normal setup or the explicit migration separately before
--force-overwrite / -ForceOverwrite. Full environment rotation preserves a
valid existing pairing key rather than silently invalidating the durable store.
Advanced custom deployment or recovery
Only deployments that intentionally bypass the supported setup scripts should set `ASSISTANT_SUBSCRIPTION_ENCRYPTION_KEY` directly. Supply exactly 32 random bytes encoded as 64 hexadecimal characters and keep the value in protected backups. Never generate a replacement while pairing ciphertext exists; restore the original instead. The explicit `--generate-assistant-subscription-key` / `-GenerateAssistantSubscriptionKey` setup options are available for controlled non-interactive migration.The gateway validates any configured value during bootstrap. A malformed, non-empty value stops the gateway before pairing becomes available; an absent value is supported only for custom deployments that deliberately keep guided pairing disabled.
The stock container paths are already wired to named persistent volumes:
| Deployment | Default pairing file | Persistent volume |
|---|---|---|
docker-compose.yml |
/opt/server/packages/api-gateway/data/assistant-subscription.json |
server-data |
docker-compose.single.yml |
/data/assistant-subscription.json |
single-data |
| Direct gateway process | ./data/assistant-subscription.json |
Operator-managed filesystem |
Override ASSISTANT_SUBSCRIPTION_TOKEN_PATH only with a path on durable,
gateway-writable storage. Container-layer or temporary paths lose pairing,
pending PKCE state, and exchange claims when the container is replaced.
PUBLIC_URL is mandatory before pairing is enabled and derives the default
callback /v1/assistant/subscription/callback. Raw Compose deliberately supplies
no localhost fallback; the setup scripts write the correct local or production
origin. This fails closed instead of accidentally deriving a callback from a
production server whose operator forgot its public origin. Operators can override the authorize
endpoint, token endpoint, OAuth client id, redirect URI, scopes, and account-id
claim with the ASSISTANT_CHATGPT_OAUTH_* variables documented in the gateway
environment sample.
Authorize, token, and redirect URLs must use HTTPS. Plain HTTP is accepted only
for localhost, 127.0.0.1, or ::1. Configured URLs containing credentials,
query parameters, or fragments are rejected so configuration secrets do not
enter navigation or request logs. The authorization request itself adds the
standard OAuth/PKCE query fields after validation.
OAuth token POSTs do not follow redirects. Any token-endpoint 3xx response
fails with a generic pairing error, so the authorization code, refresh token,
client id, and PKCE verifier are never replayed to a redirect target.
If the encryption key is absent, pairing routes report that server-held pairing
is unavailable. An explicitly configured environment token remains a legacy
boot-time fallback only for the default slot in that unconfigured state; named
slots never alias to it. Once durable pairing is enabled, the paired default
slot is authoritative: missing, repair-required, or unreadable state fails
closed and never silently resumes upstream traffic with the environment token.
Pair a named slot
Open Settings → Admin → AI → Guided ChatGPT / Codex subscription pairing.
- Enter
defaultor a named id such asteam-a. - Generate the authorization link.
- Verify the sanitized provider origin shown in the warning.
- Open the link in its isolated
noopener,noreferrertab and authorize. - Let the provider redirect to the public callback, or paste the returned authorization code into the authenticated manual-completion field.
- Check the status for that exact id.
- Reference the same id from a subscription assistant backend profile.
Ids are 1–128 ASCII letters, numbers, dots, underscores, or hyphens; they must start and end with a letter or number. Starting a newer attempt for one id invalidates older pending and in-flight attempts for that id. Other pairings remain intact.
The full one-time authorization URL is not rendered or copied by the admin UI.
Only its origin is shown for verification. The OAuth state necessarily
travels in the upstream authorization URL, but the PKCE verifier never leaves
the gateway. Neither the access credential nor the refresh credential is
returned to the app.
State, callback, and restart lifecycle
sequenceDiagram
actor Admin
participant UI as Admin UI
participant Gateway
participant Store as Encrypted local store
participant OAuth as OAuth provider
Admin->>UI: Start pairing for team-a
UI->>Gateway: POST /subscription/start
Gateway->>Store: Encrypt state, verifier, admin UUID, target, expiry
Gateway-->>UI: Authorization URL and one-time state
UI->>OAuth: Open isolated authorization tab
OAuth->>Gateway: Callback with code and state
Gateway->>Store: Atomically claim state with short lease
Gateway->>OAuth: Bounded, timed code exchange
Gateway->>Store: Commit credential only if claim is still valid
Gateway-->>Admin: Inert no-store result page
UI->>Gateway: Poll status for team-a
Pending state is encrypted in the same durable file as credentials, so a gateway restart between link generation and callback does not lose it.
- Pending attempts expire after 10 minutes.
- A code exchange holds a 2-minute encrypted claim lease.
- State is single use across concurrent processes sharing the same store.
- Expired claims are pruned and cannot wedge a target.
- A newer attempt or targeted unpair invalidates an in-flight claim, so a late exchange cannot resurrect or overwrite the slot.
- The gateway bounds lifecycle entries to 256 total, 16 per administrator, and one pending or claimed attempt per target id.
Manual completion requires the same authenticated administrator UUID that started the attempt. The public callback cannot have an application session, so its authorization contract is possession of the unguessable, encrypted, single-use OAuth state.
The callback returns inert HTML with no script or opener communication. It sets
no-store, no-referrer, nosniff, frame denial, same-origin opener isolation,
and a default-src 'none' content-security policy. The admin UI learns
completion by polling status for the exact target id.
The built-in multi- and single-container nginx front doors define an exact
callback location with access_log off, preventing the callback code and
state query from entering those nginx access logs. This does not configure
or protect logs at an outer reverse proxy, load balancer, CDN, or WAF. Disable
query logging or redact code, state, error, and error_description there
for this exact path:
/v1/assistant/subscription/callback
The gateway’s existing IP anti-abuse middleware gives the callback a dedicated fixed-window bucket, using the configured login ceiling so normal provider redirects are not coupled to registration attempts. That middleware is Redis-backed and fails open during Redis failure; the single-container in-memory deployment has no shared Redis limiter. Apply an outer-proxy limit as defense in depth, allowing several redirects per minute per client rather than a one-shot rule. Malformed state/code values are rejected before encrypted-store work, and valid-looking unknown states fail before any token-endpoint request.
Credential storage and refresh
The gateway AES-256-GCM encrypts access, refresh, and ID tokens plus non-secret account metadata. The on-disk JSON contains only an authenticated versioned envelope. File writes are bounded, atomic, and serialized across local gateway processes that share the path.
Wrong-key, malformed, or tampered stores fail closed. Status reports
storeUnreadable and does not pretend a pairing exists; listing or targeted
removal refuses to proceed. A targeted removal can never respond to corruption
by deleting the whole file.
When an access token enters the 60-second safety window, the gateway refreshes it before use:
- calls for one id share one in-process refresh;
- store-level compare-and-swap prevents another process’s stale result from overwriting a rotated or newly paired credential;
invalid_grant,invalid_token, missing refresh tokens, and credential rejection require re-pairing;- network errors, rate limits, provider 5xx responses, and other transient failures schedule bounded exponential backoff and do not mark the pairing permanently broken; and
- a token inside the safety window is not reused after refresh failure.
Status returns only safe metadata: id, paired/readability state, account label or id, expiry, repair reason, transient failure class, retry time, and backend profile references. It never returns access, refresh, or ID tokens.
Token endpoint calls time out after 15 seconds. Responses are streamed under a 512 KiB ceiling. Token and account-id values must be bounded and safe for HTTP headers; control characters and invalid or excessive expiry values are rejected. Upstream response bodies, descriptions, fetch errors, authorization codes, verifiers, and tokens are never reflected in pairing HTTP errors.
Inspect and unpair safely
The admin usage card lists every paired id and locally metered assistant usage. This is Standard Red Notes metering, not an official provider quota.
Targeted unpair requires an explicit subscriptionId. The server audits
assistant and backend profiles first:
- if none reference the id, removal affects only that id and its pending attempts;
- if profiles reference it, the UI names them and requires explicit confirmation; those profiles then fail closed until changed or the same id is paired again; and
- if settings or the profile resolver cannot be read, removal returns
503instead of guessing that no references exist.
Omitting an id never means “clear everything.” The separately named
POST /v1/assistant/subscription/unpair-all operation requires the exact
confirmation string UNPAIR ALL SUBSCRIPTIONS and clears every credential and
pending attempt. The normal web UI does not use it.
Unpairing removes the local encrypted credential. It does not currently call a verified upstream revocation endpoint. Use the provider’s account/session controls as well when revocation outside this instance matters.
HTTP contract
All routes except the provider callback require an authenticated administrator
cross-service token. Every authenticated pairing/status/list/start/complete/
unpair/unpair-all/usage response, including errors, sends
Cache-Control: private, no-store, max-age=0 and Pragma: no-cache.
| Method | Route | Contract |
|---|---|---|
GET |
/v1/assistant/subscription/status?subscriptionId=team-a |
Non-secret status for exactly one id |
GET |
/v1/assistant/subscription/list |
Sorted non-secret status for all readable pairings |
POST |
/v1/assistant/subscription/start |
Start encrypted PKCE state for an explicit/default id |
POST |
/v1/assistant/subscription/complete |
Admin-bound manual code completion |
GET |
/v1/assistant/subscription/callback |
Public single-use OAuth-state callback |
POST |
/v1/assistant/subscription/unpair |
Explicit targeted removal with profile-reference guard |
POST |
/v1/assistant/subscription/unpair-all |
Exact-confirmation destructive cleanup |
GET |
/v1/assistant/subscription/usage?subscriptionId=team-a |
Local token metering for one id |
Backup, restore, and multi-instance limits
Back up the encrypted pairing file if preserving pairings is intentional, but store its encryption key separately. Restoring the file without the exact key is unrecoverable by design. Copying the key and file together into a less protected backup removes much of the at-rest benefit.
The secure file lock coordinates processes on one host/filesystem. Gateways on separate hosts with separate local files do not share credentials, pending state, claims, or refresh CAS. A callback routed to a different host will fail safely. Use one pairing gateway instance or a genuinely shared, correctly locked storage design; sticky routing alone does not synchronize later refresh and unpair operations.
Live provider compatibility remains conditional because the upstream contract is undocumented and no live-account fixture is committed. Verify authorization, refresh, assistant requests, unpair behavior, and provider-side revocation in a non-critical account before depending on it.