Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ DASHBOARD_API_PORT=4000
# security). Defaults to http://localhost:4000 when unset; set it to the
# https://t-<id>.propr.dev host when the hosted UI tunnel is enabled.
# API_PUBLIC_URL=http://localhost:4000
# Optional lifetime for newly paired desktop instance tokens. When unset,
# tokens remain valid until the owner revokes them. Range: 1-3650 days.
# PROPR_DESKTOP_TOKEN_TTL_DAYS=90
# Optional per-IP desktop discovery/pairing quotas. Defaults are documented in
# docs/docs/operations/desktop-pairing.md.
# PROPR_DISCOVERY_RATE_LIMIT_MAX=60
# PROPR_PAIRING_START_RATE_LIMIT_MAX=10
# PROPR_PAIRING_POLL_RATE_LIMIT_MAX=180
# Session cookie domain. Leave UNSET for v1 — including hosted UI tunnel proxy
# sessions, which run on a single t-<id>.propr.dev host (see the tunnel
# section above). Only set it for a custom multi-subdomain deployment.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/concepts/security-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The API and worker use the host Docker socket to launch task containers; the API

- **Inbound: none required.** The default event intake is an outbound WebSocket to the routing service, so a stack behind NAT or a firewall works without exposing any port. The API (4000) and Web UI (5173) bind locally; expose them deliberately (reverse proxy, VPN, or the managed [hosted UI tunnel](../operations/deployment.md#hosted-ui-tunnel)).
- **`direct_webhook` mode** (advanced) is the exception: it requires a public `POST /webhook` endpoint and a webhook secret.
- **Unauthenticated endpoints:** `GET /api/compatibility` is intentionally unauthenticated so the hosted UI can check version compatibility before login — the release version of your stack is readable pre-auth. Treat that as public information or keep the API off the public internet.
- **Unauthenticated endpoints:** `GET /api/compatibility` and `GET /api/desktop/discovery` intentionally expose only product/version compatibility and desktop-auth capabilities. The rate-limited desktop pairing start/poll endpoints use a high-entropy, body-only device secret and disclose an instance token only after browser-session approval. Treat version metadata as public information or keep the API off the public internet.
- API access is protected by session auth (GitHub OAuth) and optional bearer-token auth for automation.
- **Organizations with GitHub IP allow lists**: add your ProPR server's egress IP to the org allow list. The GitHub App deliberately declares no IP allow list of its own: every API call comes from your self-hosted stack at your own address, so inheriting an App-level list would block your own stack.

Expand Down
102 changes: 102 additions & 0 deletions docs/docs/operations/desktop-pairing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 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.
Protocol version 1 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` (or the existing
`GET /api/compatibility`). The dedicated response is deliberately limited to
the product name, release/API/UI compatibility values, and this capability:

```json
{
"product": "ProPR",
"version": "0.8.15",
"apiCompatibility": "2026-06-27",
"uiCompatibility": "2026-06-27",
"desktopAuthentication": {
"protocolVersion": 1,
"browserPairing": true,
"instanceBearerTokens": true,
"socketIoBearerAuthentication": true
}
}
```

Discovery is rate limited per trusted network address. A `false` capability
means the deployment (for example, public demo mode) must not be paired.

## Pairing sequence

1. The trusted desktop process sends `POST /api/desktop/pairings` with
`{"clientName":"Alice's MacBook"}`. `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
`{"status":"complete","token":"propr_it_...","tokenType":"Bearer","expiresAt":null}`.
The polling grant is consumed in the same transaction that creates the token;
subsequent polls return `409 PAIRING_ALREADY_CONSUMED`. If the success response
is lost, begin a new pairing rather than retrying for the credential.

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. 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.

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 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`.

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.
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const sidebars: SidebarsConfig = {
'operations/propr-connect',
'operations/connect-dashboard',
'operations/hosted-ui-tunnel',
'operations/desktop-pairing',
'operations/pwa-web-push',
'operations/configuration-reference',
'operations/metrics',
Expand Down
9 changes: 8 additions & 1 deletion packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,19 @@ To run the API in development mode:

## API Endpoints

All API endpoints are protected by authentication:
All operational API endpoints are protected by authentication. Compatibility,
desktop discovery, and the bounded pairing bootstrap/poll routes are the
documented pre-authentication exceptions:

- `GET /api/auth/github` - Initiate GitHub OAuth flow
- `GET /api/auth/github/callback` - OAuth callback
- `GET /api/auth/logout` - Logout user
- `GET /api/auth/user` - Get sanitized current user info, instance role, and permissions
- `GET /api/desktop/discovery` - Public product/API compatibility and desktop-auth capabilities only
- `POST /api/desktop/pairings` - Start a short-lived browser pairing request
- `POST /api/desktop/pairings/:pairingId/poll` - Poll with the device secret in the JSON body
- `GET /api/desktop/tokens` - List the current user's safe instance-token metadata
- `DELETE /api/desktop/tokens/:tokenId` - Revoke one of the current user's instance tokens
- `GET /api/catalog` - Get the sanitized enabled repository/agent catalog needed by member workflows
- `GET /api/repositories/indexing-status` - Get indexing status projected to enabled catalog repository/branch entries
- `GET /api/admin/members` - List explicit role assignments (administrator)
Expand Down
78 changes: 71 additions & 7 deletions packages/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- browser, GitHub bearer, instance-token, and Socket.IO auth share one policy boundary */
import passport from 'passport';
import { Strategy as GitHubStrategy, Profile } from 'passport-github2';
import session from 'express-session';
Expand All @@ -7,6 +8,7 @@ import { randomBytes } from 'node:crypto';
import type { Express, Request, Response, NextFunction, RequestHandler } from 'express';
import { validateSessionSecret } from '@propr/shared';
import { validateGitHubToken } from './authBearer.js';
import { desktopAuthService, INSTANCE_TOKEN_PREFIX } from './desktopAuthService.js';
import { configureDemoMode, getDemoUser, isDemoMode } from './demoMode.js';
import { clearSessionForReauth, isGitHubTokenExpired, refreshGitHubTokenIfNeeded, refreshGitHubTokenWithResult } from './authGithubTokens.js';
import { getValidatedRedirectTo, getDefaultRedirectUrl } from './authRedirect.js';
Expand Down Expand Up @@ -50,13 +52,15 @@ export interface SocketPrincipal {

export interface SocketAuthenticationDependencies {
validateToken: typeof validateGitHubToken;
validateInstanceToken?: typeof desktopAuthService.validateToken;
isWhitelisted: typeof isUserWhitelisted;
resolveInstanceAuthorization: typeof resolveInstanceAuthorization;
refreshToken: typeof refreshGitHubTokenWithResult;
}

const defaultSocketAuthenticationDependencies: SocketAuthenticationDependencies = {
validateToken: validateGitHubToken,
validateInstanceToken: token => desktopAuthService.validateToken(token),
isWhitelisted: isUserWhitelisted,
resolveInstanceAuthorization,
refreshToken: refreshGitHubTokenWithResult,
Expand Down Expand Up @@ -324,6 +328,8 @@ export function setupAuth(app: Express, demoModeAtStartup = isDemoMode()): Socke
* HTTP API. Browser clients normally arrive with a Passport session cookie;
* non-browser clients may provide the normal Authorization: Bearer header.
*/
// Session refresh and two bearer credential classes intentionally fail closed here.
// eslint-disable-next-line complexity
export async function authenticateSocketRequest(
req: Request,
dependencies: SocketAuthenticationDependencies = defaultSocketAuthenticationDependencies,
Expand All @@ -350,27 +356,49 @@ export async function authenticateSocketRequest(
throw new SocketAuthenticationError('USER_NOT_WHITELISTED', 'GitHub user is not allowed');
}

req.authenticationMethod = 'session';
return {
user: req.user,
authorization: await dependencies.resolveInstanceAuthorization(req.user),
};
}

const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false';
const rawAuthHeader = req.headers.authorization;
const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
if (bearerEnabled && authHeader?.startsWith('Bearer ')) {
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();
if (!token) {
throw new SocketAuthenticationError('INVALID_BEARER_TOKEN', 'Bearer token is empty');
}
if (token.startsWith(INSTANCE_TOKEN_PREFIX)) {
const identity = await (dependencies.validateInstanceToken
? dependencies.validateInstanceToken(token)
: desktopAuthService.validateToken(token));
if (!identity) {
throw new SocketAuthenticationError('INVALID_INSTANCE_TOKEN', 'Instance token is invalid');
}
if (!dependencies.isWhitelisted(identity.user.username)) {
throw new SocketAuthenticationError('USER_NOT_WHITELISTED', 'GitHub user is not allowed');
}
req.authenticationMethod = 'instance_token';
req.instanceTokenId = identity.tokenId;
return {
user: identity.user,
authorization: await dependencies.resolveInstanceAuthorization(identity.user),
};
}
const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false';
if (!bearerEnabled) {
throw new SocketAuthenticationError('AUTHENTICATION_REQUIRED', 'Authentication required');
}
const user = await dependencies.validateToken(token);
if (!user) {
throw new SocketAuthenticationError('INVALID_BEARER_TOKEN', 'Bearer token is invalid');
}
if (!dependencies.isWhitelisted(user.username)) {
throw new SocketAuthenticationError('USER_NOT_WHITELISTED', 'GitHub user is not allowed');
}
req.authenticationMethod = 'github_bearer';
return {
user,
authorization: await dependencies.resolveInstanceAuthorization(user),
Expand All @@ -380,12 +408,20 @@ export async function authenticateSocketRequest(
throw new SocketAuthenticationError('AUTHENTICATION_REQUIRED', 'Authentication required');
}

export async function ensureAuthenticated(req: Request, res: Response, next: NextFunction): Promise<void> {
// Keep REST precedence identical to Socket.IO: demo, session, instance token, GitHub bearer.
// eslint-disable-next-line complexity
export async function ensureAuthenticated(
req: Request,
res: Response,
next: NextFunction,
validateInstanceToken: (token: string) => ReturnType<typeof desktopAuthService.validateToken> = token => desktopAuthService.validateToken(token),
): Promise<void> {
if (isDemoMode()) {
res.set('X-ProPR-Demo-Mode', 'true');
// Demo mode is deployment-wide: browser callers receive the synthetic read-only user.
// Stale bearer headers are ignored so public demo visitors are treated consistently.
(req as Request & { user: GitHubUser }).user = getDemoUser();
req.authenticationMethod = 'demo';
return next();
}

Expand Down Expand Up @@ -424,15 +460,42 @@ export async function ensureAuthenticated(req: Request, res: Response, next: Nex
console.error('Background token refresh failed:', err);
});
}
req.authenticationMethod = 'session';
return next();
}

// Bearer token auth (CLI)
const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false';
// Bearer token auth (desktop instance token or optional GitHub token for CLI)
const authHeader = req.headers.authorization;

if (bearerEnabled && authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();

if (token.startsWith(INSTANCE_TOKEN_PREFIX)) {
try {
const identity = await validateInstanceToken(token);
if (!identity) {
res.status(401).json({ error: 'Unauthorized: invalid instance token', code: 'INVALID_INSTANCE_TOKEN' });
return;
}
if (!isUserWhitelisted(identity.user.username)) {
res.status(403).json({ error: 'Forbidden', code: 'USER_NOT_WHITELISTED', message: 'Your GitHub account is not authorized for this ProPR instance. Ask an admin to add you to the user whitelist.' });
return;
}
(req as Request & { user: GitHubUser }).user = identity.user;
req.authenticationMethod = 'instance_token';
req.instanceTokenId = identity.tokenId;
return next();
} catch {
res.status(401).json({ error: 'Unauthorized: instance token validation failed', code: 'INVALID_INSTANCE_TOKEN' });
return;
}
}

const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false';
if (!bearerEnabled) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

try {
const user = await validateGitHubToken(token);
Expand All @@ -443,6 +506,7 @@ export async function ensureAuthenticated(req: Request, res: Response, next: Nex
}
// Populate req.user so downstream handlers work the same way
(req as Request & { user: GitHubUser }).user = user;
req.authenticationMethod = 'github_bearer';
return next();
}
res.status(401).json({ error: 'Unauthorized: invalid token' });
Expand Down
Loading
Loading