Skip to main content

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​

  1. The trusted desktop process repeats strict unauthenticated discovery at the exact candidate origin. It then sends POST /api/desktop/pairings with the client name and its main-owned profile/origin/scope/credential-generation binding. clientName is printable text from 1 through 80 characters.
  2. A 201 response contains pairingId, deviceSecret, approvalUrl, expiresAt, and interval (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 exact approvalUrl in the system browser. Do not append a redirect or origin supplied by the renderer.
  3. 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}/approve accepts only an authenticated browser session and the exact configured FRONTEND_URL origin. GitHub bearer and instance-token principals cannot approve a pairing.
  4. No more often than interval, the trusted process sends POST /api/desktop/pairings/{pairingId}/poll with {"deviceSecret":"..."}. The secret is in the JSON body, never a URL or header that an intermediary normally logs. A pending request returns 202 with {"status":"pending","interval":5}.
  5. The first valid poll after approval returns 200 with a provisional credential: {"status":"provisional","token":"propr_it_...","tokenType":"Bearer","activationTicket":"...","activationExpiresAt":"...",...}, plus the pairing's instanceId, origin, scope, and credentialGeneration binding. A provisional token authenticates nothing. Repeating the poll before activation returns the same token and ticket, so a lost response can be recovered.
  6. After storing the token securely, the trusted process sends POST /api/desktop/pairings/{pairingId}/activate with deviceSecret, activationTicket, and the same binding fields. A 200 response {"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 to POST /api/desktop/pairings/{pairingId}/cancel. Once a pairing is consumed, further polls return 409 PAIRING_ALREADY_CONSUMED (or 410 PAIRING_CANCELLED after 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/tokens returns { "tokens": [...] } with id, name, tokenHint, createdAt, lastUsedAt, expiresAt, and revokedAt. It never returns a hash or token.
  • DELETE /api/desktop/tokens/{tokenId} returns 204 after revoking an active owned token. Unknown, already-revoked, and other users' IDs all return 404 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.