Open API token lifecycle
cTrader's Open API allows one valid access token per cTrader ID (cID) at a time. The moment a new token is issued — a scheduled refresh, or a re-authorization when the user links another account on the same cID — the previous access token is invalidated. A copy engine running on a remote node is holding that now-dead token, so the new token must reach it without dropping the live connection.
Model
OpenApiAuthorizationis the aggregate that holds a cID's encrypted access + refresh tokens. A unique index on(UserId, CtidUserId)enforces exactly one authorization per cID per user.TokenVersion— a monotonic counter bumped every time the token rotates (Refresh(), which also covers the re-auth path when another account is linked on the same cID). It is the version marker for the single-valid-token rule and is what a running host uses to detect a change even if two token strings happen to collide.- Tokens are encrypted at rest via
ISecretProtector(EncryptionPurposes.OpenApiAccessToken/OpenApiRefreshToken). They are never logged or stored in plaintext.
Propagation (graceful in-place swap)
- A token rotates → the new token + bumped
TokenVersionare persisted. - The
CopyEngineSupervisoron the hosting node re-reads the plan each reconcile cycle and computes a token signature (access tokens + versions). A change means a rotation. - Instead of tearing down the host and restarting (which would drop the master's execution stream), the supervisor pushes the new token to the running host.
- The host re-authenticates the affected account on the existing socket
(
ProtoOAAccountAuthReqagain) viaSwapAccessTokenAsync, then does a light reconcile. The old token dies; the copy stream never stops.
This is what makes the cross-cID case safe: a user adding a second account from the same cID mid-run invalidates the old token, and the running copy profile keeps going on the new one.
Refresh
OpenApiTokenRefreshService (background) proactively refreshes authorizations before expiry;
OpenApiAuthorization.IsExpiring(threshold, now) gates it. cTrader rotates the refresh token
on every refresh, so the new refresh token is persisted immediately; a read-only cache that can't
persist would self-invalidate (relevant to the in-cluster test Job, which mounts a writable copy
of the secret).
Failure escalation
A failed refresh is not silent. OpenApiAuthorization.MarkRefreshFailed(reason, now, criticalWindow)
records RefreshFailedAt, increments ConsecutiveRefreshFailures, and always raises
AccessTokenRefreshFailed (warning). When the token is now within App:OpenApi:TokenRefreshCriticalWindow
(default 6h) of expiry and refresh is still failing, it escalates once with an
AccessTokenRefreshCritical domain event + Critical log so the owner can re-authorize before
copy/prop-firm operations lose the token. The failure counter and escalation latch reset on the next
successful Refresh. The service keeps retrying every TokenRefreshInterval, so a provider/maintenance
outage self-heals when the refresh endpoint returns.
Invalidation alert & auto-recovery (M1)
A partial/again-authorization on a cID invalidates the token a running copy host still holds. When a
trading call rejects with OpenApiErrorKind.TokenInvalid, the host raises a distinct
CopyTokenInvalidated alert (log 1078) — not a generic failure — so the notification channel knows a
token needs attention. Recovery is automatic: the supervisor re-reads the authorization each cycle and,
when the refreshed token changes the token signature, pushes it into the running host for an in-place
swap — copying resumes with no manual re-add. A NotLinkable profile (token/auth temporarily
unresolvable) is likewise re-evaluated every supervisor cycle and hosted the moment its plan builds again.
Host liveness watchdog (M2)
The supervisor watches each hosted profile's run task. If a host exits or faults while its profile is
still assigned to this node, the watchdog cancels and restarts it next cycle (log
CopyHostRestarted), so a wedged host self-heals instead of needing a manual restart — and one profile's
failure never stalls the others (per-profile isolation).
Tests
- Unit —
TokenVersionbumps onRefresh; host performs an in-place swap without restart; cross-cID invalidation swaps source and destination tokens; an invalidated destination token raisesCopyTokenInvalidatedand auto-recovers on the next token push (M1); the watchdogIsHostDeaddecision restarts a completed/faulted host and leaves a reassigned profile alone (M2). - Integration —
TokenVersionpersists + increments through EF on real Postgres; the token signature changes on a version bump even if the string is unchanged.