Desktop pairing protocol
Packaged desktop clients authenticate to one ProPR instance with an opaque instance token. They never receive or persist a GitHub access or refresh token. Desktop authentication protocol version 2 is designed for the Electron main process (or another trusted native process); renderer code must communicate with it through a narrow IPC bridge and must not read the device secret or instance token.
Discovery
Before login, call GET /api/desktop/discovery. The discovery document retains
schema version 1 and advertises desktop authentication protocol version 2. It is
deliberately limited to the exact product, release/API/UI compatibility,
canonical managed endpoint, random public installation identity, and
authentication capabilities:
{
"schemaVersion": 1,
"product": "ProPR",
"version": "0.9.0",
"apiCompatibility": "2026-06-27",
"uiCompatibility": "2026-06-27",
"canonicalEndpoint": "https://t-abc123.propr.dev",
"publicInstanceIdentity": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"desktopAuthentication": {
"protocolVersion": 2,
"browserPairing": true,
"instanceBearerTokens": true,
"socketIoBearerAuthentication": true
}
}
Consumers must parse the entire schema-v1 document and require desktop
authentication protocol version 2 before using any field. The version is
canonical SemVer; both compatibility values are canonical YYYY-MM-DD versions;
the identity is an exact lowercase UUIDv4; and the endpoint is either null
during restart/configuration or the bare canonical
https://t-<id>.propr.dev origin. Every capability key is required and every
capability value is a JSON boolean. Missing, extra, duplicate, oversized,
coerced, malformed, or non-canonical fields are incompatible discovery, never
partial readiness. Native and shared-client consumers use the same bounded wire
parser.
The public identity is not a credential. It is randomly created in the stack's
private durable data/ directory and is shared by the host CLI and root-running
API container. The directory remains owned by the host caller with mode 0700.
The single-link regular identity file may be owned by that host caller or by the
root API container account; it is never group/world writable, and a root-owned
file remains host-readable. Creation writes and fsyncs a private same-directory
temporary, publishes without replacing a concurrent winner, and fsyncs the
directory. Normal restarts, upgrades, and tunnel rotation preserve the value;
replacing the durable data directory creates a new identity.
Discovery is rate limited per trusted network address. A false capability
means the deployment (for example, public demo mode) must not be paired. The
legacy GET /api/compatibility metadata is not a substitute for the schema-v1
discovery and identity contract.
Pairing sequence
- The trusted desktop process repeats strict unauthenticated discovery at the
exact candidate origin. It then sends
POST /api/desktop/pairingswith the client name and its main-owned profile/origin/scope/credential-generation binding.clientNameis printable text from 1 through 80 characters. - A
201response containspairingId,deviceSecret,approvalUrl,expiresAt, andinterval(seconds). Both identifiers have at least 128 bits of entropy; the device secret has 256 bits. Store the secret only in trusted process memory and open the exactapprovalUrlin the system browser. Do not append a redirect or origin supplied by the renderer. - The browser entry validates the unexpired request, initiates the instance's
normal GitHub login when necessary, and redirects to the fixed ProPR approval
page. The approval page shows the client name and requires an explicit click.
POST /api/desktop/pairings/{pairingId}/approveaccepts only an authenticated browser session and the exact configuredFRONTEND_URLorigin. GitHub bearer and instance-token principals cannot approve a pairing. - No more often than
interval, the trusted process sendsPOST /api/desktop/pairings/{pairingId}/pollwith{"deviceSecret":"..."}. The secret is in the JSON body, never a URL or header that an intermediary normally logs. A pending request returns202with{"status":"pending","interval":5}. - The first valid poll after approval returns
200with a provisional credential:{"status":"provisional","token":"propr_it_...","tokenType":"Bearer","activationTicket":"...","activationExpiresAt":"...",...}, plus the pairing'sinstanceId,origin,scope, andcredentialGenerationbinding. A provisional token authenticates nothing. Repeating the poll before activation returns the same token and ticket, so a lost response can be recovered. - After storing the token securely, the trusted process sends
POST /api/desktop/pairings/{pairingId}/activatewithdeviceSecret,activationTicket, and the same binding fields. A200response{"status":"active","receipt":"...","activatedAt":"...","expiresAt":null}makes the token usable and consumes the pairing; repeating the same activation returns the same receipt. Activation must happen within two minutes of the first provisional poll and before the pairing expires. To abandon a provisioned credential instead, send the same body toPOST /api/desktop/pairings/{pairingId}/cancel. Once a pairing is consumed, further polls return409 PAIRING_ALREADY_CONSUMED(or410 PAIRING_CANCELLEDafter a cancel).
Pairings expire after ten minutes. An unknown ID or wrong secret returns the
same 404 PAIRING_NOT_FOUND; an expired request returns 410 PAIRING_EXPIRED.
Start and poll routes have separate IP quotas; activate and cancel share the
poll quota. Clients must honor HTTP 429 and Retry-After and must stop at
expiresAt.
Using and storing the token
Send the returned token as Authorization: Bearer propr_it_... on normal REST
requests. For Socket.IO, set that same header on the Engine.IO WebSocket
handshake (Electron/Node clients can use extraHeaders). The socket identity is
revalidated periodically, so token revocation, expiry, role changes, permission
changes, or whitelist removal disconnect an established client.
Store the token in an operating-system credential facility such as macOS
Keychain, Windows Credential Manager, or Linux Secret Service. Never put it in
localStorage, IndexedDB, renderer state, a pairing URL, logs, crash reports, or
analytics. Keep the instance origin with the credential and refuse to send it to
another origin. Treat TLS certificate failures as terminal; HTTP is accepted
only for loopback development. Persist the discovery publicInstanceIdentity
with the encrypted credential and bind it atomically to the profile ID,
canonical origin, and credential generation. Before a stored token is used
after launch, reconnect, profile switch, or tunnel rotation, repeat
unauthenticated strict discovery at that exact origin. An absent, malformed, or
different identity produces no bearer-, cookie-, or socket-authenticated
request, durably detaches the old credential, and requires a new pairing
generation. Legacy credentials without this binding fail closed and are removed
locally during migration.
The server stores SHA-256 token and device-secret hashes, never plaintext. Token
rows retain the owner GitHub ID/profile snapshot, creation and last-use times,
optional expiry, and revocation metadata. Authorization still resolves the
owner's current instance role and permissions on each request. Set
PROPR_DESKTOP_TOKEN_TTL_DAYS to an integer from 1 through 3650 to issue expiring
tokens; when unset, tokens remain valid until revoked. Expired pairing rows are
cleaned hourly after a short retention period used for stable client errors.
Token management
Both routes require any accepted authentication method and operate only on the authenticated user's activated tokens:
GET /api/desktop/tokensreturns{ "tokens": [...] }withid,name,tokenHint,createdAt,lastUsedAt,expiresAt, andrevokedAt. It never returns a hash or token.DELETE /api/desktop/tokens/{tokenId}returns204after revoking an active owned token. Unknown, already-revoked, and other users' IDs all return404 TOKEN_NOT_FOUND.
A desktop client can also revoke its own credential with
DELETE /api/desktop/tokens/current, presenting the token as the bearer
credential and its credential generation in the
X-ProPR-Desktop-Revocation-Binding header; it returns 204 on success.
Pairing start, approval, token issuance, and revocation write audit rows and structured logs containing IDs and the display name only. Device secrets, instance tokens, token hashes, and GitHub tokens are excluded.
The separate optional nightly live GitHub smoke uses a preauthorized dedicated runner browser session for signed Linux previews. It is independent of the deterministic installed-RPM publication gate.