From 58f39ea2b53789487d04bf12f265b7729813feea Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:02:22 -0700 Subject: [PATCH] Implement secure device pairing sessions Add signed pairing-code exchange, hashed device session secrets, identity device session management, desktop pairing onboarding, docs, and regression coverage. --- .../main/__tests__/bridge-contracts.test.ts | 2 +- desktop/src/main/bridge.ts | 75 +- .../src/shared/desktop-bridge-contracts.ts | 2 + docs/API-REFERENCE.md | 80 +- docs/IDENTITY-RBAC.md | 25 +- .../__tests__/device-session-service.test.ts | 332 +++++++ server/src/__tests__/middleware/auth.test.ts | 137 +++ server/src/__tests__/routes/auth.test.ts | 108 ++- server/src/__tests__/routes/identity.test.ts | 68 +- server/src/middleware/auth.ts | 45 +- server/src/routes/auth.ts | 117 ++- server/src/routes/identity.ts | 93 +- server/src/services/device-session-service.ts | 877 ++++++++++++++++++ server/src/storage/index.ts | 6 + .../sqlite/device-session-repository.ts | 580 ++++++++++++ server/src/storage/sqlite/migrations.ts | 99 ++ shared/src/utils/api-permissions.ts | 15 +- web/src/__tests__/desktop-onboarding.test.tsx | 1 + web/src/__tests__/multi-user-tab.test.tsx | 31 + web/src/components/auth/DesktopOnboarding.tsx | 14 +- .../components/settings/tabs/MultiUserTab.tsx | 510 +++++++++- web/src/hooks/useIdentity.tsx | 54 ++ web/src/lib/api/identity.ts | 135 +++ 23 files changed, 3338 insertions(+), 68 deletions(-) create mode 100644 server/src/__tests__/device-session-service.test.ts create mode 100644 server/src/services/device-session-service.ts create mode 100644 server/src/storage/sqlite/device-session-repository.ts diff --git a/desktop/src/main/__tests__/bridge-contracts.test.ts b/desktop/src/main/__tests__/bridge-contracts.test.ts index 1f50aca6..35265aea 100644 --- a/desktop/src/main/__tests__/bridge-contracts.test.ts +++ b/desktop/src/main/__tests__/bridge-contracts.test.ts @@ -246,7 +246,7 @@ describe('desktop bridge contracts', () => { valid: true, normalizedServerUrl: 'https://remote.example/veritas', }); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://remote.example/api/auth/status'); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://remote.example/api/auth/context'); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: 'Bearer vk_pat_secret' }, }); diff --git a/desktop/src/main/bridge.ts b/desktop/src/main/bridge.ts index 5a635ffc..cc96bef5 100644 --- a/desktop/src/main/bridge.ts +++ b/desktop/src/main/bridge.ts @@ -48,17 +48,37 @@ async function validateRemoteConnection( }; } - const statusUrl = new URL('/api/auth/status', config.serverUrl); + let serverToken = config.serverToken; + const warnings: string[] = []; + + if (!serverToken && config.pairingPayload) { + const paired = await exchangeRemotePairingPayload(config.serverUrl, config.pairingPayload); + if (!paired.secret) { + return { + mode: 'remote', + valid: false, + normalizedServerUrl: config.serverUrl, + warnings, + errors: paired.errors, + }; + } + serverToken = paired.secret; + warnings.push('Pairing payload was exchanged for a device session.'); + } + + const statusUrl = new URL( + serverToken ? '/api/auth/context' : '/api/auth/status', + config.serverUrl + ); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5_000); try { const response = await fetch(statusUrl, { method: 'GET', - headers: config.serverToken ? { Authorization: `Bearer ${config.serverToken}` } : undefined, + headers: serverToken ? { Authorization: `Bearer ${serverToken}` } : undefined, signal: controller.signal, }); - const warnings: string[] = []; if (response.status === 401 || response.status === 403) { warnings.push('Remote server is reachable but rejected the supplied credentials.'); } @@ -92,6 +112,55 @@ async function validateRemoteConnection( } } +async function exchangeRemotePairingPayload( + serverUrl: string, + pairingPayload: string +): Promise<{ secret: string | null; errors: string[] }> { + const exchangeUrl = new URL('/api/auth/device-pairing/exchange', serverUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5_000); + + try { + const response = await fetch(exchangeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(parseRemotePairingPayload(pairingPayload)), + signal: controller.signal, + }); + if (!response.ok) { + return { + secret: null, + errors: [`Pairing exchange returned HTTP ${response.status}.`], + }; + } + + const body = (await response.json()) as { secret?: unknown }; + return typeof body.secret === 'string' + ? { secret: body.secret, errors: [] } + : { secret: null, errors: ['Pairing exchange did not return a device session secret.'] }; + } catch (error) { + return { secret: null, errors: [redactDesktopBridgeError(error)] }; + } finally { + clearTimeout(timeout); + } +} + +function parseRemotePairingPayload(pairingPayload: string): unknown { + const trimmed = pairingPayload.trim(); + if (trimmed.startsWith('veritas://pair')) { + const url = new URL(trimmed); + const encoded = url.searchParams.get('payload'); + if (!encoded) { + throw new Error('Pairing link is missing payload.'); + } + return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + } + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed); + } + return { code: trimmed }; +} + export function createDesktopBridgeHandlers( runtime: DesktopRuntime, shell: Shell, diff --git a/desktop/src/shared/desktop-bridge-contracts.ts b/desktop/src/shared/desktop-bridge-contracts.ts index f19190a9..2730c125 100644 --- a/desktop/src/shared/desktop-bridge-contracts.ts +++ b/desktop/src/shared/desktop-bridge-contracts.ts @@ -52,6 +52,7 @@ export interface DesktopConnectionConfigRequest { mode: DesktopConnectionModeRequest; serverUrl?: string; serverToken?: string; + pairingPayload?: string; workspaceId?: string; } @@ -721,6 +722,7 @@ export function validateConnectionConfigRequest(payload: unknown): DesktopConnec mode: 'remote', serverUrl: parsed.toString(), serverToken: optionalSecret(request.serverToken, 'Remote server token'), + pairingPayload: optionalSecret(request.pairingPayload, 'Remote pairing payload'), workspaceId, }; } diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 73683137..c2a91b42 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -56,11 +56,12 @@ VK supports three authentication methods. All are optional when running locally ### Methods -| Method | Header / Param | Use Case | -| ---------------------- | --------------------------------- | --------------------------- | -| **Bearer Token** (JWT) | `Authorization: Bearer ` | Browser sessions, UI login | -| **API Key** | `X-API-Key: ` | Agent integrations, scripts | -| **WS Query Param** | `ws://host:port/ws?api_key=` | WebSocket connections | +| Method | Header / Param | Use Case | +| ---------------------- | --------------------------------- | --------------------------------- | +| **Bearer Token** (JWT) | `Authorization: Bearer ` | Browser sessions, UI login | +| **API Key** | `X-API-Key: ` | Agent integrations, scripts | +| **Device Session** | `Authorization: Bearer vk_dev_…` | Paired desktop/mobile/PWA clients | +| **WS Query Param** | `ws://host:port/ws?api_key=` | WebSocket connections | ### Roles @@ -74,10 +75,12 @@ VK supports three authentication methods. All are optional when running locally Protected REST handlers and WebSocket connections receive a shared auth context: `role`, `userId`, `workspaceId`, `actorType`, `authMethod`, `tokenName`, and -role-derived `permissions`. Existing endpoints still accept the compatibility -roles above, but new v5 route work should declare the specific permission it -requires, such as `task:read`, `task:write`, `workflow:execute`, or -`admin:manage`. +role-derived `permissions`. Device sessions also include `deviceSessionId`, +`deviceId`, `clientId`, `clientMode`, `capabilities`, and `degradedReason` when +a current workspace role downgrade trimmed the approved scopes. Existing +endpoints still accept the compatibility roles above, but new v5 route work +should declare the specific permission it requires, such as `task:read`, +`task:write`, `workflow:execute`, or `admin:manage`. ### Localhost Bypass @@ -603,6 +606,7 @@ POST /api/auth/recover # Account recovery POST /api/auth/change-password # Change password POST /api/auth/rotate-secret # Rotate JWT secret GET /api/auth/rotation-status # JWT rotation status +POST /api/auth/device-pairing/exchange # Redeem a one-time pairing payload ``` ### Login Example @@ -631,19 +635,23 @@ v5 adds SQLite-backed identity management for users, workspaces, memberships, roles, and invitations. These endpoints are mounted at `/api/identity` and `/api/v1/identity`. -| Method | Path | Description | -| -------- | --------------------------------------------------- | ------------------------------------------------ | -| `GET` | `/api/identity/profile` | Current user profile plus workspace memberships. | -| `GET` | `/api/identity/workspaces` | Workspaces available to the current user. | -| `POST` | `/api/identity/workspaces/switch` | Validate/select an active workspace membership. | -| `GET` | `/api/identity/workspaces/:workspaceId/members` | List active workspace members. | -| `GET` | `/api/identity/workspaces/:workspaceId/invitations` | List invitations. Requires admin. | -| `POST` | `/api/identity/workspaces/:workspaceId/invitations` | Create an invitation. Requires admin. | -| `POST` | `/api/identity/invitations/accept` | Accept an invitation. | -| `POST` | `/api/auth/invitations/accept` | Accept an invitation before login. | -| `POST` | `/api/identity/invitations/:id/revoke` | Revoke a pending invitation. Requires admin. | -| `PATCH` | `/api/identity/workspaces/:workspaceId/members/:id` | Update a member role. Requires admin. | -| `DELETE` | `/api/identity/workspaces/:workspaceId/members/:id` | Remove a member. Requires admin. | +| Method | Path | Description | +| -------- | ------------------------------------------------------------------ | ----------------------------------------------------- | +| `GET` | `/api/identity/profile` | Current user profile plus workspace memberships. | +| `GET` | `/api/identity/workspaces` | Workspaces available to the current user. | +| `POST` | `/api/identity/workspaces/switch` | Validate/select an active workspace membership. | +| `GET` | `/api/identity/workspaces/:workspaceId/members` | List active workspace members. | +| `GET` | `/api/identity/workspaces/:workspaceId/invitations` | List invitations. Requires admin. | +| `POST` | `/api/identity/workspaces/:workspaceId/invitations` | Create an invitation. Requires admin. | +| `POST` | `/api/identity/invitations/accept` | Accept an invitation. | +| `POST` | `/api/auth/invitations/accept` | Accept an invitation before login. | +| `POST` | `/api/identity/invitations/:id/revoke` | Revoke a pending invitation. Requires admin. | +| `PATCH` | `/api/identity/workspaces/:workspaceId/members/:id` | Update a member role. Requires admin. | +| `DELETE` | `/api/identity/workspaces/:workspaceId/members/:id` | Remove a member. Requires admin. | +| `GET` | `/api/identity/workspaces/:workspaceId/device-sessions` | List trusted device sessions. Requires admin. | +| `POST` | `/api/identity/workspaces/:workspaceId/device-pairing-codes` | Create a short-lived pairing payload. Requires admin. | +| `POST` | `/api/identity/workspaces/:workspaceId/device-sessions/:id/test` | Test current device session state. Requires admin. | +| `POST` | `/api/identity/workspaces/:workspaceId/device-sessions/:id/revoke` | Revoke a trusted device session. Requires admin. | ### Create Invitation @@ -677,6 +685,34 @@ POST /api/auth/invitations/accept Membership mutations are recorded in audit and activity history. +### Device Pairing + +```http +POST /api/identity/workspaces/local/device-pairing-codes +``` + +```json +{ + "deviceName": "Brad phone", + "clientMode": "mobile-pwa", + "capabilities": ["workspace:read", "task:read"], + "scopes": ["workspace:read", "task:read"], + "role": "read-only" +} +``` + +The response returns a plaintext `code` and `veritas://pair?...` link once. +SQLite stores only hashes for pairing codes and device session secrets. Clients +redeem the returned payload through: + +```http +POST /api/auth/device-pairing/exchange +``` + +Pairing payloads include client id, client mode, capabilities, device id, +scopes, role, workspace, nonce, signed timestamp, and signature. Codes expire +quickly, cannot be reused, and failed attempts are rate-limited and audited. + --- ## Telemetry diff --git a/docs/IDENTITY-RBAC.md b/docs/IDENTITY-RBAC.md index a7b65ec4..de3e482b 100644 --- a/docs/IDENTITY-RBAC.md +++ b/docs/IDENTITY-RBAC.md @@ -37,6 +37,7 @@ auditable actor attribution, and route-level plus entity-level permissions. | Actor type | Principal id | Auth method | Typical use | | ------------------ | --------------------- | --------------------------------------------------- | ----------------------------------------------------- | | `user` | `users.id` | password session, future SSO subject, recovery flow | Browser, desktop, or mobile human user. | +| `device` | `device_sessions.id` | paired device session | Desktop remote, mobile/PWA, browser, and CLI pairing. | | `agent` | `agent_identities.id` | scoped agent API token | OpenClaw, local Codex, workflow workers, MCP clients. | | `service` | `api_tokens.id` | scoped service API token | Dashboards, webhooks, import/export jobs. | | `system` | `system` | internal | Migrations, retention jobs, scheduled cleanup. | @@ -46,15 +47,21 @@ auditable actor attribution, and route-level plus entity-level permissions. ```ts interface AuthContext { - actorType: 'user' | 'agent' | 'service' | 'system' | 'localhost-bypass'; + actorType: 'user' | 'device' | 'agent' | 'service' | 'system' | 'localhost-bypass'; actorId: string; displayName: string; workspaceId: string; role: WorkspaceRole; permissions: Permission[]; - authMethod: 'session' | 'api-token' | 'recovery' | 'localhost-bypass' | 'system'; + authMethod: 'session' | 'api-key' | 'device-session' | 'recovery' | 'localhost-bypass' | 'system'; sessionId?: string; tokenId?: string; + deviceSessionId?: string; + deviceId?: string; + clientId?: string; + clientMode?: string; + capabilities?: string[]; + degradedReason?: string | null; isLocalhost: boolean; } ``` @@ -462,11 +469,15 @@ Rollback expectation: ### Mobile or Remote Device Pairing -1. Owner/admin enables remote pairing for a workspace. -2. Device completes login or pairing challenge. -3. App creates a `device_sessions` row with device metadata and expiry. -4. Session can be revoked by the user, admin, or owner. -5. Remote requests are never treated as localhost bypass. +1. Owner/admin creates a short-lived pairing payload for a workspace. +2. Device redeems the code/link with its client id, client mode, capabilities, + nonce, signed timestamp, and signature. +3. App creates a `device_sessions` row with hashed secret, device metadata, + scopes, capabilities, role, and expiry. +4. Session appears in account security UI with last seen, connection state, + degraded reason, and revoke/test actions. +5. Remote requests authenticate with `authMethod: device-session` and are never + treated as localhost bypass. ### Lost Admin Recovery diff --git a/server/src/__tests__/device-session-service.test.ts b/server/src/__tests__/device-session-service.test.ts new file mode 100644 index 00000000..4fca62ab --- /dev/null +++ b/server/src/__tests__/device-session-service.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AppError, ForbiddenError, ValidationError } from '../middleware/error-handler.js'; +import { + DeviceSessionService, + hashDeviceSessionSecret, + type CreateDevicePairingResult, +} from '../services/device-session-service.js'; +import { createTestSqliteDatabase } from '../storage/sqlite/test-helpers.js'; +import { SqliteDeviceSessionRepository } from '../storage/sqlite/device-session-repository.js'; +import { SqliteIdentityRepository } from '../storage/sqlite/identity-repository.js'; +import type { IdentityActor } from '../services/identity-service.js'; + +function createService() { + const fixture = createTestSqliteDatabase(); + fixture.database.open(); + const identityRepository = new SqliteIdentityRepository(fixture.database); + const sessionRepository = new SqliteDeviceSessionRepository(fixture.database); + const audit = vi.fn().mockResolvedValue(undefined); + const activity = { logActivity: vi.fn().mockResolvedValue(undefined) }; + const service = new DeviceSessionService({ + identityRepository, + sessionRepository, + audit, + activity, + }); + const owner = identityRepository.ensureLocalOwner({ displayName: 'Owner' }); + const ownerActor = { + userId: owner.user.id, + role: 'owner', + displayName: owner.user.displayName, + permissions: ['*'], + } satisfies IdentityActor; + + return { + fixture, + identityRepository, + sessionRepository, + service, + audit, + activity, + ownerActor, + }; +} + +async function createMobilePairing( + service: DeviceSessionService, + ownerActor: IdentityActor +): Promise { + return service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'Brad phone', + deviceType: 'pwa', + clientId: 'mobile-client-1', + clientMode: 'mobile-pwa', + capabilities: ['workspace:read', 'task:read', 'task:write'], + scopes: ['workspace:read', 'task:read', 'task:write'], + role: 'member', + }, + ownerActor + ); +} + +describe('DeviceSessionService', () => { + it('creates one-use pairing codes, redeems them into hashed device sessions, and validates secrets', async () => { + const { fixture, service, sessionRepository, audit, activity, ownerActor } = createService(); + + try { + const pairing = await createMobilePairing(service, ownerActor); + const redeemed = await service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: pairing.payload.nonce, + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }); + const authRecord = sessionRepository.getSessionForAuthByHash( + hashDeviceSessionSecret(redeemed.secret) + ); + const validation = service.validateSecret(redeemed.secret, '192.168.1.10'); + + expect(pairing.code).toMatch(/^vk_pair_/); + expect(pairing.link).toContain('veritas://pair?'); + expect(redeemed.secret).toMatch(/^vk_dev_/); + expect(authRecord?.tokenHash).not.toBe(redeemed.secret); + expect(validation.valid).toBe(true); + expect(validation.auth).toMatchObject({ + actorType: 'device', + authMethod: 'device-session', + workspaceId: 'local', + userId: 'local-user', + deviceSessionId: redeemed.session.id, + deviceId: redeemed.session.deviceId, + clientId: 'mobile-client-1', + clientMode: 'mobile-pwa', + permissions: ['workspace:read', 'task:read', 'task:write'], + }); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'identity.device_pairing.create', resource: 'local' }) + ); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'identity.device_pairing.exchange', resource: 'local' }) + ); + expect(activity.logActivity).toHaveBeenCalledWith( + 'membership_updated', + 'workspace:local', + 'Workspace local', + expect.objectContaining({ action: 'identity.device_pairing.exchange' }) + ); + } finally { + fixture.cleanup(); + } + }); + + it('rejects expired pairing codes and one-use replay attempts', async () => { + const { fixture, service, ownerActor } = createService(); + + try { + const expired = await createMobilePairing(service, ownerActor); + fixture.database + .getConnection() + .prepare('UPDATE device_pairing_codes SET expires_at = ? WHERE id = ?') + .run('2000-01-01T00:00:00.000Z', expired.pairing.id); + + await expect( + service.exchangePairingCode({ + code: expired.code, + clientId: expired.payload.clientId, + clientMode: expired.payload.clientMode, + capabilities: expired.payload.capabilities, + nonce: expired.payload.nonce, + signedAt: expired.payload.signedAt, + signature: expired.payload.signature, + }) + ).rejects.toBeInstanceOf(ValidationError); + + const oneUse = await createMobilePairing(service, ownerActor); + await service.exchangePairingCode({ + code: oneUse.code, + clientId: oneUse.payload.clientId, + clientMode: oneUse.payload.clientMode, + capabilities: oneUse.payload.capabilities, + nonce: oneUse.payload.nonce, + signedAt: oneUse.payload.signedAt, + signature: oneUse.payload.signature, + }); + + await expect( + service.exchangePairingCode({ + code: oneUse.code, + clientId: oneUse.payload.clientId, + clientMode: oneUse.payload.clientMode, + capabilities: oneUse.payload.capabilities, + nonce: oneUse.payload.nonce, + signedAt: oneUse.payload.signedAt, + signature: oneUse.payload.signature, + }) + ).rejects.toBeInstanceOf(ValidationError); + } finally { + fixture.cleanup(); + } + }); + + it('rejects stale nonce, client-mode downgrade, brute-force attempts, and scope escalation', async () => { + const { fixture, service, ownerActor } = createService(); + + try { + const pairing = await service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'Desktop peer', + deviceType: 'desktop', + clientId: 'desktop-client-1', + clientMode: 'desktop-remote', + capabilities: ['desktop:remote', 'agent:run:scoped'], + scopes: ['workspace:read', 'task:read', 'task:write'], + role: 'member', + }, + ownerActor + ); + + await expect( + service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: pairing.payload.nonce, + signedAt: '2000-01-01T00:00:00.000Z', + signature: pairing.payload.signature, + }) + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: 'stale-nonce-stale-nonce', + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }) + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: 'mobile-pwa', + capabilities: pairing.payload.capabilities, + nonce: pairing.payload.nonce, + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }) + ).rejects.toBeInstanceOf(ForbiddenError); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect( + service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: `bad-nonce-${attempt}-bad-nonce`, + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }) + ).rejects.toBeInstanceOf(ValidationError); + } + + await expect( + service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: 'bad-nonce-limit-bad-nonce', + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }) + ).rejects.toBeInstanceOf(AppError); + + const escalation = await service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'Scoped desktop', + deviceType: 'desktop', + clientId: 'desktop-client-2', + clientMode: 'desktop-remote', + scopes: ['workspace:read', 'task:read'], + role: 'member', + }, + ownerActor + ); + + await expect( + service.exchangePairingCode({ + code: escalation.code, + clientId: escalation.payload.clientId, + clientMode: escalation.payload.clientMode, + capabilities: escalation.payload.capabilities, + scopes: ['workspace:read', 'task:read', 'task:write'], + nonce: escalation.payload.nonce, + signedAt: escalation.payload.signedAt, + signature: escalation.payload.signature, + }) + ).rejects.toBeInstanceOf(ForbiddenError); + + await expect( + service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'Escalation phone', + deviceType: 'pwa', + clientId: 'mobile-client-2', + clientMode: 'mobile-pwa', + scopes: ['settings:write'], + role: 'member', + }, + ownerActor + ) + ).rejects.toBeInstanceOf(ForbiddenError); + } finally { + fixture.cleanup(); + } + }); + + it('rejects revoked device secrets and clamps permissions after workspace role downgrade', async () => { + const { fixture, service, ownerActor } = createService(); + + try { + const pairing = await createMobilePairing(service, ownerActor); + const redeemed = await service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: pairing.payload.nonce, + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }); + + expect(service.validateSecret(redeemed.secret).auth?.permissions).toContain('task:write'); + fixture.database + .getConnection() + .prepare('UPDATE workspace_memberships SET role = ? WHERE workspace_id = ? AND user_id = ?') + .run('read-only', 'local', ownerActor.userId); + + const downgraded = service.validateSecret(redeemed.secret); + expect(downgraded.valid).toBe(true); + expect(downgraded.auth?.role).toBe('read-only'); + expect(downgraded.auth?.degradedReason).toBe('role_downgraded'); + expect(downgraded.auth?.permissions).toEqual(['workspace:read', 'task:read']); + + fixture.database + .getConnection() + .prepare('UPDATE workspace_memberships SET role = ? WHERE workspace_id = ? AND user_id = ?') + .run('owner', 'local', ownerActor.userId); + await service.revokeSession(redeemed.session.id, ownerActor); + + expect(service.validateSecret(redeemed.secret).valid).toBe(false); + const testResult = service.testSession(redeemed.session.id, ownerActor); + expect(testResult.allowed).toBe(false); + expect(testResult.reason).toBe('revoked'); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/server/src/__tests__/middleware/auth.test.ts b/server/src/__tests__/middleware/auth.test.ts index 6b5dd37c..2a7d28a9 100644 --- a/server/src/__tests__/middleware/auth.test.ts +++ b/server/src/__tests__/middleware/auth.test.ts @@ -36,8 +36,13 @@ import { import { getSecurityConfig, getValidJwtSecrets } from '../../config/security.js'; import jwt from 'jsonwebtoken'; import { ApiTokenService, resetApiTokenServiceForTests } from '../../services/api-token-service.js'; +import { + DeviceSessionService, + resetDeviceSessionServiceForTests, +} from '../../services/device-session-service.js'; import { createTestSqliteDatabase } from '../../storage/sqlite/test-helpers.js'; import { SqliteApiTokenRepository } from '../../storage/sqlite/api-token-repository.js'; +import { SqliteDeviceSessionRepository } from '../../storage/sqlite/device-session-repository.js'; import { SqliteIdentityRepository } from '../../storage/sqlite/identity-repository.js'; import type { IdentityActor } from '../../services/identity-service.js'; @@ -90,6 +95,7 @@ describe('Auth Middleware', () => { afterEach(() => { resetApiTokenServiceForTests(); + resetDeviceSessionServiceForTests(); process.env = { ...originalEnv }; vi.restoreAllMocks(); }); @@ -281,6 +287,73 @@ describe('Auth Middleware', () => { } }); + it('should authenticate via SQLite device session token', async () => { + const fixture = createTestSqliteDatabase(); + fixture.database.open(); + process.env.VERITAS_SQLITE_PATH = fixture.databasePath; + + const identityRepository = new SqliteIdentityRepository(fixture.database); + const deviceSessionRepository = new SqliteDeviceSessionRepository(fixture.database); + const service = new DeviceSessionService({ + identityRepository, + sessionRepository: deviceSessionRepository, + audit: vi.fn().mockResolvedValue(undefined), + activity: { logActivity: vi.fn().mockResolvedValue(undefined) }, + }); + const owner = identityRepository.ensureLocalOwner({ displayName: 'Owner' }); + const pairing = await service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'Mobile device', + deviceType: 'pwa', + clientId: 'mobile-auth-client', + clientMode: 'mobile-pwa', + capabilities: ['workspace:read', 'task:read'], + scopes: ['workspace:read', 'task:read'], + role: 'read-only', + }, + { + userId: owner.user.id, + role: 'owner', + displayName: owner.user.displayName, + permissions: ['*'], + } + ); + const paired = await service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: pairing.payload.nonce, + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }); + resetDeviceSessionServiceForTests(); + + try { + const req = mockRequest({ + headers: { authorization: `Bearer ${paired.secret}` }, + socket: { remoteAddress: '192.168.1.101' } as any, + ip: '192.168.1.101', + }) as AuthenticatedRequest; + const res = mockResponse(); + const next = mockNext(); + + authenticate(req, res, next); + expect(next).toHaveBeenCalled(); + expect(req.auth?.role).toBe('read-only'); + expect(req.auth?.actorType).toBe('device'); + expect(req.auth?.authMethod).toBe('device-session'); + expect(req.auth?.deviceSessionId).toBe(paired.session.id); + expect(req.auth?.deviceId).toBe(paired.session.deviceId); + expect(req.auth?.clientId).toBe('mobile-auth-client'); + expect(req.auth?.clientMode).toBe('mobile-pwa'); + expect(req.auth?.permissions).toEqual(['workspace:read', 'task:read']); + } finally { + fixture.cleanup(); + } + }); + it('should allow localhost bypass with read-only role by default', () => { process.env.VERITAS_AUTH_LOCALHOST_BYPASS = 'true'; const req = mockRequest({ @@ -794,6 +867,70 @@ describe('Auth Middleware', () => { } }); + it('should authenticate WebSocket device session tokens from the query parameter', async () => { + const fixture = createTestSqliteDatabase(); + fixture.database.open(); + process.env.VERITAS_SQLITE_PATH = fixture.databasePath; + + const identityRepository = new SqliteIdentityRepository(fixture.database); + const deviceSessionRepository = new SqliteDeviceSessionRepository(fixture.database); + const service = new DeviceSessionService({ + identityRepository, + sessionRepository: deviceSessionRepository, + audit: vi.fn().mockResolvedValue(undefined), + activity: { logActivity: vi.fn().mockResolvedValue(undefined) }, + }); + const owner = identityRepository.ensureLocalOwner({ displayName: 'Owner' }); + const pairing = await service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'WebSocket mobile', + deviceType: 'pwa', + clientId: 'ws-mobile-client', + clientMode: 'mobile-pwa', + capabilities: ['workspace:read', 'task:read'], + scopes: ['workspace:read', 'task:read'], + role: 'read-only', + }, + { + userId: owner.user.id, + role: 'owner', + displayName: owner.user.displayName, + permissions: ['*'], + } + ); + const paired = await service.exchangePairingCode({ + code: pairing.code, + clientId: pairing.payload.clientId, + clientMode: pairing.payload.clientMode, + capabilities: pairing.payload.capabilities, + nonce: pairing.payload.nonce, + signedAt: pairing.payload.signedAt, + signature: pairing.payload.signature, + }); + resetDeviceSessionServiceForTests(); + + try { + const req = { + headers: { host: 'localhost:3001' }, + url: `/ws?api_key=${encodeURIComponent(paired.secret)}`, + socket: { remoteAddress: '192.168.1.101' }, + } as unknown as IncomingMessage; + + const result = authenticateWebSocket(req); + expect(result.authenticated).toBe(true); + expect(result.role).toBe('read-only'); + expect(result.actorType).toBe('device'); + expect(result.authMethod).toBe('device-session'); + expect(result.deviceSessionId).toBe(paired.session.id); + expect(result.deviceId).toBe(paired.session.deviceId); + expect(result.clientId).toBe('ws-mobile-client'); + expect(result.permissions).toEqual(['workspace:read', 'task:read']); + } finally { + fixture.cleanup(); + } + }); + it('should authenticate via JWT cookie', () => { const secret = 'test-secret-key'; const token = jwt.sign({ type: 'session' }, secret, { expiresIn: '1h' }); diff --git a/server/src/__tests__/routes/auth.test.ts b/server/src/__tests__/routes/auth.test.ts index 79c8fc00..c864be44 100644 --- a/server/src/__tests__/routes/auth.test.ts +++ b/server/src/__tests__/routes/auth.test.ts @@ -26,7 +26,9 @@ vi.mock('../../config/security.js', () => { securityConfig = config; }, getJwtSecret: () => securityConfig.jwtSecret || 'test-secret-key-for-jwt-signing-12345678', - getValidJwtSecrets: () => [securityConfig.jwtSecret || 'test-secret-key-for-jwt-signing-12345678'], + getValidJwtSecrets: () => [ + securityConfig.jwtSecret || 'test-secret-key-for-jwt-signing-12345678', + ], generateRecoveryKey: () => 'RECOVERY-KEY-12345678', hashRecoveryKey: async (key: string) => { return crypto.createHash('sha256').update(key).digest('hex'); @@ -48,6 +50,13 @@ vi.mock('../../config/security.js', () => { // Import auth route after mocking import authRouter from '../../routes/auth.js'; import { errorHandler } from '../../middleware/error-handler.js'; +import { + DeviceSessionService, + resetDeviceSessionServiceForTests, +} from '../../services/device-session-service.js'; +import { createTestSqliteDatabase } from '../../storage/sqlite/test-helpers.js'; +import { SqliteDeviceSessionRepository } from '../../storage/sqlite/device-session-repository.js'; +import { SqliteIdentityRepository } from '../../storage/sqlite/identity-repository.js'; describe('Auth Routes', () => { let app: express.Express; @@ -67,6 +76,11 @@ describe('Auth Routes', () => { app.use(errorHandler); }); + afterEach(() => { + resetDeviceSessionServiceForTests(); + delete process.env.VERITAS_SQLITE_PATH; + }); + describe('GET /api/auth/status', () => { it('should indicate setup is needed when no password set', async () => { const res = await request(app).get('/api/auth/status'); @@ -93,7 +107,7 @@ describe('Auth Routes', () => { const res = await request(app) .get('/api/auth/status') .set('Cookie', `veritas_session=${token}`); - + expect(res.status).toBe(200); expect(res.body.authenticated).toBe(true); expect(res.body.sessionExpiry).toBeDefined(); @@ -106,12 +120,67 @@ describe('Auth Routes', () => { const res = await request(app) .get('/api/auth/status') .set('Cookie', 'veritas_session=invalid-token'); - + expect(res.status).toBe(200); expect(res.body.authenticated).toBe(false); }); }); + describe('POST /api/auth/device-pairing/exchange', () => { + it('redeems a pairing payload and authenticates the returned device secret', async () => { + const fixture = createTestSqliteDatabase(); + fixture.database.open(); + process.env.VERITAS_SQLITE_PATH = fixture.databasePath; + + try { + const identityRepository = new SqliteIdentityRepository(fixture.database); + const sessionRepository = new SqliteDeviceSessionRepository(fixture.database); + const service = new DeviceSessionService({ + identityRepository, + sessionRepository, + audit: vi.fn().mockResolvedValue(undefined), + activity: { logActivity: vi.fn().mockResolvedValue(undefined) }, + }); + const owner = identityRepository.ensureLocalOwner({ displayName: 'Owner' }); + const pairing = await service.createPairingCode( + { + workspaceId: 'local', + deviceName: 'Route phone', + clientMode: 'mobile-pwa', + capabilities: ['workspace:read', 'task:read'], + scopes: ['workspace:read', 'task:read'], + role: 'read-only', + }, + { + userId: owner.user.id, + role: 'owner', + displayName: owner.user.displayName, + permissions: ['*'], + } + ); + resetDeviceSessionServiceForTests(); + + const exchanged = await request(app) + .post('/api/auth/device-pairing/exchange') + .send({ payload: pairing.payload }) + .expect(201); + + expect(exchanged.body.secret).toMatch(/^vk_dev_/); + expect(exchanged.body.session.tokenHash).toBeUndefined(); + + const context = await request(app) + .get('/api/auth/context') + .set('Authorization', `Bearer ${exchanged.body.secret}`) + .expect(200); + expect(context.body.authMethod).toBe('device-session'); + expect(context.body.actorType).toBe('device'); + expect(context.body.deviceSessionId).toBe(exchanged.body.session.id); + } finally { + fixture.cleanup(); + } + }); + }); + describe('POST /api/auth/setup', () => { it('should set up password on first run', async () => { const res = await request(app) @@ -127,27 +196,21 @@ describe('Auth Routes', () => { it('should reject setup when password already exists', async () => { securityConfig.passwordHash = 'existing-hash'; - const res = await request(app) - .post('/api/auth/setup') - .send({ password: 'newpassword123' }); + const res = await request(app).post('/api/auth/setup').send({ password: 'newpassword123' }); expect(res.status).toBe(400); expect(res.body.code).toBe('ALREADY_SETUP'); }); it('should reject missing password', async () => { - const res = await request(app) - .post('/api/auth/setup') - .send({}); + const res = await request(app).post('/api/auth/setup').send({}); expect(res.status).toBe(400); expect(res.body.code).toBe('MISSING_PASSWORD'); }); it('should reject short password', async () => { - const res = await request(app) - .post('/api/auth/setup') - .send({ password: 'short' }); + const res = await request(app).post('/api/auth/setup').send({ password: 'short' }); expect(res.status).toBe(400); expect(res.body.code).toBe('PASSWORD_TOO_SHORT'); @@ -161,9 +224,7 @@ describe('Auth Routes', () => { }); it('should login with correct password', async () => { - const res = await request(app) - .post('/api/auth/login') - .send({ password: 'correctpassword' }); + const res = await request(app).post('/api/auth/login').send({ password: 'correctpassword' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -173,18 +234,14 @@ describe('Auth Routes', () => { }); it('should reject wrong password', async () => { - const res = await request(app) - .post('/api/auth/login') - .send({ password: 'wrongpassword' }); + const res = await request(app).post('/api/auth/login').send({ password: 'wrongpassword' }); expect(res.status).toBe(401); expect(res.body.code).toBe('INVALID_PASSWORD'); }); it('should reject missing password', async () => { - const res = await request(app) - .post('/api/auth/login') - .send({}); + const res = await request(app).post('/api/auth/login').send({}); expect(res.status).toBe(400); expect(res.body.code).toBe('MISSING_PASSWORD'); @@ -193,9 +250,7 @@ describe('Auth Routes', () => { it('should reject login when no password configured', async () => { securityConfig.passwordHash = null; - const res = await request(app) - .post('/api/auth/login') - .send({ password: 'anything' }); + const res = await request(app).post('/api/auth/login').send({ password: 'anything' }); expect(res.status).toBe(400); expect(res.body.code).toBe('NOT_SETUP'); @@ -211,16 +266,21 @@ describe('Auth Routes', () => { }); it('should rate limit after too many failures', async () => { + app.set('trust proxy', true); + const forwardedIp = '203.0.113.42'; + // Send 5 wrong passwords for (let i = 0; i < 5; i++) { await request(app) .post('/api/auth/login') + .set('X-Forwarded-For', forwardedIp) .send({ password: 'wrong' }); } // 6th should be rate limited const res = await request(app) .post('/api/auth/login') + .set('X-Forwarded-For', forwardedIp) .send({ password: 'wrong' }); expect(res.status).toBe(429); diff --git a/server/src/__tests__/routes/identity.test.ts b/server/src/__tests__/routes/identity.test.ts index c0531766..021db1ef 100644 --- a/server/src/__tests__/routes/identity.test.ts +++ b/server/src/__tests__/routes/identity.test.ts @@ -5,9 +5,11 @@ import type { AuthenticatedRequest } from '../../middleware/auth.js'; import { errorHandler } from '../../middleware/error-handler.js'; import { createIdentityRoutes } from '../../routes/identity.js'; import { ApiTokenService } from '../../services/api-token-service.js'; +import { DeviceSessionService } from '../../services/device-session-service.js'; import { IdentityService } from '../../services/identity-service.js'; import { createTestSqliteDatabase } from '../../storage/sqlite/test-helpers.js'; import { SqliteApiTokenRepository } from '../../storage/sqlite/api-token-repository.js'; +import { SqliteDeviceSessionRepository } from '../../storage/sqlite/device-session-repository.js'; import { SqliteIdentityRepository } from '../../storage/sqlite/identity-repository.js'; function createApp(role: 'admin' | 'agent' | 'read-only' = 'admin') { @@ -15,6 +17,7 @@ function createApp(role: 'admin' | 'agent' | 'read-only' = 'admin') { fixture.database.open(); const repository = new SqliteIdentityRepository(fixture.database); const tokenRepository = new SqliteApiTokenRepository(fixture.database); + const deviceSessionRepository = new SqliteDeviceSessionRepository(fixture.database); const service = new IdentityService({ repository, audit: vi.fn().mockResolvedValue(undefined), @@ -26,6 +29,12 @@ function createApp(role: 'admin' | 'agent' | 'read-only' = 'admin') { audit: vi.fn().mockResolvedValue(undefined), activity: { logActivity: vi.fn().mockResolvedValue(undefined) }, }); + const deviceSessionService = new DeviceSessionService({ + identityRepository: repository, + sessionRepository: deviceSessionRepository, + audit: vi.fn().mockResolvedValue(undefined), + activity: { logActivity: vi.fn().mockResolvedValue(undefined) }, + }); service.ensureOwnerSetup({ displayName: 'Owner' }); const app = express(); @@ -41,10 +50,10 @@ function createApp(role: 'admin' | 'agent' | 'read-only' = 'admin') { }; next(); }); - app.use('/identity', createIdentityRoutes(service, apiTokenService)); + app.use('/identity', createIdentityRoutes(service, apiTokenService, deviceSessionService)); app.use(errorHandler); - return { app, fixture }; + return { app, fixture, deviceSessionService }; } describe('identity routes', () => { @@ -128,4 +137,59 @@ describe('identity routes', () => { fixture.cleanup(); } }); + + it('creates pairing codes and revokes device sessions without exposing hashes', async () => { + const { app, fixture, deviceSessionService } = createApp(); + + try { + const created = await request(app) + .post('/identity/workspaces/local/device-pairing-codes') + .send({ + deviceName: 'Brad phone', + deviceType: 'pwa', + clientId: 'route-mobile-client', + clientMode: 'mobile-pwa', + capabilities: ['workspace:read', 'task:read', 'task:write'], + scopes: ['workspace:read', 'task:read', 'task:write'], + role: 'member', + }) + .expect(201); + + expect(created.body.code).toMatch(/^vk_pair_/); + expect(created.body.pairing.codeHash).toBeUndefined(); + expect(created.body.link).toContain('veritas://pair?'); + + const redeemed = await deviceSessionService.exchangePairingCode({ + code: created.body.code, + clientId: created.body.payload.clientId, + clientMode: created.body.payload.clientMode, + capabilities: created.body.payload.capabilities, + nonce: created.body.payload.nonce, + signedAt: created.body.payload.signedAt, + signature: created.body.payload.signature, + }); + + const listed = await request(app) + .get('/identity/workspaces/local/device-sessions') + .expect(200); + expect(listed.body).toHaveLength(1); + expect(listed.body[0].tokenHash).toBeUndefined(); + + const revoked = await request(app) + .post(`/identity/workspaces/local/device-sessions/${redeemed.session.id}/revoke`) + .send() + .expect(200); + expect(revoked.body.revokedAt).toBeTruthy(); + expect(revoked.body.connectionState).toBe('revoked'); + + const tested = await request(app) + .post(`/identity/workspaces/local/device-sessions/${redeemed.session.id}/test`) + .send() + .expect(200); + expect(tested.body.allowed).toBe(false); + expect(tested.body.reason).toBe('revoked'); + } finally { + fixture.cleanup(); + } + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 218edeff..04f840e9 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -6,14 +6,15 @@ import jwt from 'jsonwebtoken'; import { getSecurityConfig, getJwtSecret, getValidJwtSecrets } from '../config/security.js'; import { createLogger } from '../lib/logger.js'; import { validateScopedApiToken } from '../services/api-token-service.js'; +import { validateDeviceSessionSecret } from '../services/device-session-service.js'; const log = createLogger('auth'); // === Types === export type AuthRole = 'admin' | 'read-only' | 'agent'; -export type AuthMethod = 'disabled' | 'session' | 'api-key' | 'localhost-bypass'; -export type AuthActorType = 'user' | 'agent' | 'service' | 'localhost-bypass'; +export type AuthMethod = 'disabled' | 'session' | 'api-key' | 'device-session' | 'localhost-bypass'; +export type AuthActorType = 'user' | 'agent' | 'service' | 'device' | 'localhost-bypass'; export type AuthPermission = | '*' | 'workspace:read' @@ -79,6 +80,12 @@ export interface AuthContext { authMethod?: AuthMethod; tokenName?: string; permissions?: AuthPermission[]; + deviceSessionId?: string; + deviceId?: string; + clientId?: string; + clientMode?: string; + capabilities?: string[]; + degradedReason?: string | null; } const ROLE_PERMISSIONS: Record = { @@ -351,6 +358,20 @@ function validateDatabaseApiToken( } } +function validateDatabaseDeviceSession( + apiKey: string, + req: Request | IncomingMessage, + isLocalhost: boolean +): AuthContext | null { + try { + const validation = validateDeviceSessionSecret(apiKey, requestRemoteAddress(req)); + return validation.valid && validation.auth ? { ...validation.auth, isLocalhost } : null; + } catch (error) { + log.warn({ err: error }, 'Device session validation failed'); + return null; + } +} + function requestRemoteAddress(req: Request | IncomingMessage): string | null { if ('socket' in req && req.socket) { return req.socket.remoteAddress ?? null; @@ -451,6 +472,12 @@ export function authenticate(req: AuthenticatedRequest, res: Response, next: Nex req.auth = scopedAuth; return next(); } + + const deviceAuth = validateDatabaseDeviceSession(apiKey, req, isLocalhost); + if (deviceAuth) { + req.auth = deviceAuth; + return next(); + } } // 3. Localhost bypass (dev mode) — role is configurable (default: read-only) @@ -644,6 +671,12 @@ export interface WebSocketAuthResult { authMethod?: AuthMethod; tokenName?: string; permissions?: AuthPermission[]; + deviceSessionId?: string; + deviceId?: string; + clientId?: string; + clientMode?: string; + capabilities?: string[]; + degradedReason?: string | null; error?: string; } @@ -727,6 +760,14 @@ export function authenticateWebSocket(req: IncomingMessage): WebSocketAuthResult ...scopedAuth, }; } + + const deviceAuth = validateDatabaseDeviceSession(apiKey, req, isLocalhost); + if (deviceAuth) { + return { + authenticated: true, + ...deviceAuth, + }; + } } // 3. Localhost bypass — role is configurable (default: read-only) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 7523152d..819d28f7 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -14,9 +14,20 @@ import { rotateJwtSecret, getJwtRotationStatus, } from '../config/security.js'; -import { authenticate, authorize, type AuthenticatedRequest } from '../middleware/auth.js'; +import { + authenticate, + authorize, + type AuthenticatedRequest, + type AuthPermission, +} from '../middleware/auth.js'; +import { ValidationError } from '../middleware/error-handler.js'; import { auditLog } from '../services/audit-service.js'; import { getIdentityService } from '../services/identity-service.js'; +import { SCOPED_API_TOKEN_PERMISSIONS } from '../services/api-token-service.js'; +import { + getDeviceSessionService, + type ExchangeDevicePairingInput, +} from '../services/device-session-service.js'; const router: IRouter = Router(); @@ -53,6 +64,47 @@ const acceptInvitationSchema = z.object({ email: z.string().email().optional(), }); +const devicePairingPayloadSchema = z.object({ + code: z.string().min(6).max(120).optional(), + nonce: z.string().min(16).max(128).optional(), + signedAt: z.string().datetime().optional(), + signature: z.string().min(16).max(256).optional(), + clientId: z.string().trim().min(1).max(160).optional(), + clientMode: z + .enum(['desktop-remote', 'desktop-local', 'mobile-pwa', 'browser', 'cli']) + .optional(), + capabilities: z.array(z.string().trim().min(1).max(80)).optional(), + scopes: z.array(z.enum(SCOPED_API_TOKEN_PERMISSIONS)).optional(), +}); + +const redeemPairingCodeSchema = devicePairingPayloadSchema.extend({ + pairingCode: z.string().min(6).max(120).optional(), + payload: devicePairingPayloadSchema.optional(), +}); + +type RedeemPairingCodeBody = z.infer; + +function pairingExchangeInputFromBody(body: RedeemPairingCodeBody): ExchangeDevicePairingInput { + const payload = body.payload ?? {}; + return { + code: requiredPairingField(payload.code ?? body.code ?? body.pairingCode, 'code'), + nonce: requiredPairingField(payload.nonce ?? body.nonce, 'nonce'), + signedAt: requiredPairingField(payload.signedAt ?? body.signedAt, 'signedAt'), + signature: requiredPairingField(payload.signature ?? body.signature, 'signature'), + clientId: body.clientId ?? payload.clientId, + clientMode: body.clientMode ?? payload.clientMode, + capabilities: body.capabilities ?? payload.capabilities, + scopes: (body.scopes ?? payload.scopes) as AuthPermission[] | undefined, + }; +} + +function requiredPairingField(value: string | undefined, field: string): string { + if (!value) { + throw new ValidationError('Invalid pairing request', [{ path: field, message: 'Required' }]); + } + return value; +} + // Constants const SALT_ROUNDS = process.env.NODE_ENV === 'test' ? 4 : 12; const JWT_EXPIRY_DEFAULT = '24h'; @@ -181,10 +233,73 @@ router.get( authMethod: req.auth?.authMethod, tokenName: req.auth?.tokenName, permissions: req.auth?.permissions ?? [], + deviceSessionId: req.auth?.deviceSessionId, + deviceId: req.auth?.deviceId, + clientId: req.auth?.clientId, + clientMode: req.auth?.clientMode, + capabilities: req.auth?.capabilities, + degradedReason: req.auth?.degradedReason, }); }) ); +/** + * @openapi + * /api/auth/device-pairing/exchange: + * post: + * summary: Redeem a short-lived pairing code for a device session + * tags: [Auth] + * security: [] + * responses: + * 201: + * description: Device session created. The secret is returned once. + */ +router.post( + '/device-pairing/exchange', + asyncHandler(async (req: Request, res: Response) => { + const parsed = redeemPairingCodeSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + code: 'VALIDATION_ERROR', + message: 'Invalid pairing request', + details: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }); + return; + } + + const result = await getDeviceSessionService().exchangePairingCode( + pairingExchangeInputFromBody(parsed.data) + ); + res.status(201).json(result); + }) +); + +router.post( + '/pairing/redeem', + asyncHandler(async (req: Request, res: Response) => { + const parsed = redeemPairingCodeSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + code: 'VALIDATION_ERROR', + message: 'Invalid pairing request', + details: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }); + return; + } + + const result = await getDeviceSessionService().exchangePairingCode( + pairingExchangeInputFromBody(parsed.data) + ); + res.status(201).json(result); + }) +); + /** * @openapi * /api/auth/setup: diff --git a/server/src/routes/identity.ts b/server/src/routes/identity.ts index 8a3b367c..b306dd79 100644 --- a/server/src/routes/identity.ts +++ b/server/src/routes/identity.ts @@ -12,6 +12,10 @@ import { SCOPED_API_TOKEN_PERMISSIONS, type ApiTokenService, } from '../services/api-token-service.js'; +import { + getDeviceSessionService, + type DeviceSessionService, +} from '../services/device-session-service.js'; import { getIdentityService, type IdentityActor, @@ -43,13 +47,36 @@ const createApiTokenSchema = z.object({ expiresAt: z.string().datetime().optional().nullable(), }); +const deviceClientModeSchema = z.enum([ + 'desktop-remote', + 'desktop-local', + 'mobile-pwa', + 'browser', + 'cli', +]); + +const createPairingCodeSchema = z.object({ + deviceName: z.string().trim().min(1).max(120), + deviceType: z.string().trim().min(1).max(80).optional(), + deviceId: z.string().trim().min(1).max(160).optional(), + clientId: z.string().trim().min(1).max(120).optional(), + clientMode: deviceClientModeSchema.optional(), + capabilities: z.array(z.string().trim().min(1).max(80)).optional(), + scopes: z.array(z.enum(SCOPED_API_TOKEN_PERMISSIONS)).min(1).optional(), + role: roleSchema.optional(), + expiresAt: z.string().datetime().optional().nullable(), + sessionExpiresAt: z.string().datetime().optional().nullable(), +}); + export function createIdentityRoutes( service?: IdentityService, - apiTokenService?: ApiTokenService + apiTokenService?: ApiTokenService, + deviceSessionService?: DeviceSessionService ): RouterType { const router: RouterType = Router(); const serviceForRequest = () => service ?? getIdentityService(); const tokenServiceForRequest = () => apiTokenService ?? getApiTokenService(); + const deviceSessionServiceForRequest = () => deviceSessionService ?? getDeviceSessionService(); router.get( '/profile', @@ -228,6 +255,70 @@ export function createIdentityRoutes( }) ); + router.get( + '/workspaces/:workspaceId/device-sessions', + authorizePermission('admin:manage'), + asyncHandler(async (req, res) => { + res.json( + deviceSessionServiceForRequest().listSessions( + String(req.params.workspaceId), + actorFromRequest(req as AuthenticatedRequest) + ) + ); + }) + ); + + router.post( + ['/workspaces/:workspaceId/device-pairing-codes', '/workspaces/:workspaceId/pairing-codes'], + authorizePermission('admin:manage'), + asyncHandler(async (req, res) => { + const body = parseBody(createPairingCodeSchema, req.body); + const result = await deviceSessionServiceForRequest().createPairingCode( + { + workspaceId: String(req.params.workspaceId), + deviceName: body.deviceName, + deviceType: body.deviceType, + deviceId: body.deviceId, + clientId: body.clientId, + clientMode: body.clientMode, + capabilities: body.capabilities, + scopes: body.scopes as AuthPermission[] | undefined, + role: body.role, + expiresAt: body.expiresAt, + sessionExpiresAt: body.sessionExpiresAt, + }, + actorFromRequest(req as AuthenticatedRequest) + ); + res.status(201).json(result); + }) + ); + + router.post( + '/workspaces/:workspaceId/device-sessions/:sessionId/revoke', + authorizePermission('admin:manage'), + asyncHandler(async (req, res) => { + const session = await deviceSessionServiceForRequest().revokeSession( + String(req.params.sessionId), + actorFromRequest(req as AuthenticatedRequest), + String(req.params.workspaceId) + ); + res.json(session); + }) + ); + + router.post( + '/workspaces/:workspaceId/device-sessions/:sessionId/test', + authorizePermission('admin:manage'), + asyncHandler(async (req, res) => { + const result = deviceSessionServiceForRequest().testSession( + String(req.params.sessionId), + actorFromRequest(req as AuthenticatedRequest), + String(req.params.workspaceId) + ); + res.json(result); + }) + ); + return router; } diff --git a/server/src/services/device-session-service.ts b/server/src/services/device-session-service.ts new file mode 100644 index 00000000..407dd87e --- /dev/null +++ b/server/src/services/device-session-service.ts @@ -0,0 +1,877 @@ +import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; +import type { AuthContext, AuthPermission, AuthRole } from '../middleware/auth.js'; +import { + AppError, + ForbiddenError, + NotFoundError, + ValidationError, +} from '../middleware/error-handler.js'; +import { ActivityService } from './activity-service.js'; +import { auditLog, type AuditEvent } from './audit-service.js'; +import type { IdentityActor } from './identity-service.js'; +import { SCOPED_API_TOKEN_PERMISSIONS } from './api-token-service.js'; +import { + SqliteDatabase, + resolveSqliteDatabasePath, + type SqliteConnectionOptions, +} from '../storage/sqlite/database.js'; +import { + SqliteDeviceSessionRepository, + type DeviceConnectionState, + type DevicePairingCodeRecord, + type DeviceSessionRecord, +} from '../storage/sqlite/device-session-repository.js'; +import { + SqliteIdentityRepository, + type WorkspaceMembership, + type WorkspaceRole, +} from '../storage/sqlite/identity-repository.js'; + +const DEVICE_SESSION_SECRET_PREFIX = 'vk_dev_'; +const PAIRING_CODE_PREFIX = 'vk_pair_'; +const TOKEN_PREFIX_LENGTH = 16; +const PAIRING_CODE_TTL_MS = 10 * 60 * 1000; +const PAIRING_PAYLOAD_MAX_AGE_MS = 10 * 60 * 1000; +const CLOCK_SKEW_MS = 60 * 1000; +const DEVICE_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000; +const MAX_PAIRING_ATTEMPTS = 5; + +const MANAGER_ROLES = new Set(['owner', 'admin']); +const CLIENT_MODES = ['desktop-remote', 'desktop-local', 'mobile-pwa', 'browser', 'cli'] as const; +const CLIENT_MODE_SET = new Set(CLIENT_MODES); +const SCOPED_PERMISSION_SET = new Set(SCOPED_API_TOKEN_PERMISSIONS); +const WRITE_PERMISSIONS = new Set([ + 'task:write', + 'comment:write', + 'workflow:write', + 'workflow:execute', + 'work_product:write', + 'telemetry:write', + 'agent:write', + 'settings:write', + 'policy:write', + 'backup:write', + 'admin:manage', +]); + +const WORKSPACE_ROLE_PERMISSIONS: Record = { + owner: SCOPED_API_TOKEN_PERMISSIONS, + admin: SCOPED_API_TOKEN_PERMISSIONS, + member: [ + 'workspace:read', + 'task:read', + 'task:write', + 'comment:write', + 'workflow:read', + 'workflow:execute', + 'work_product:read', + 'work_product:write', + 'report:read', + 'telemetry:read', + 'agent:read', + 'settings:read', + ], + reviewer: [ + 'workspace:read', + 'task:read', + 'comment:write', + 'workflow:read', + 'work_product:read', + 'report:read', + 'telemetry:read', + 'agent:read', + 'settings:read', + 'policy:read', + ], + 'read-only': [ + 'workspace:read', + 'task:read', + 'workflow:read', + 'work_product:read', + 'report:read', + 'telemetry:read', + 'agent:read', + 'settings:read', + 'policy:read', + 'backup:read', + ], + agent: [ + 'workspace:read', + 'task:read', + 'task:write', + 'comment:write', + 'workflow:read', + 'workflow:execute', + 'work_product:read', + 'work_product:write', + 'report:read', + 'telemetry:write', + 'agent:read', + ], +}; + +const CLIENT_MODE_SCOPE_DENY: Record = { + 'desktop-remote': [], + 'desktop-local': [], + cli: [], + browser: ['agent:write', 'backup:write', 'admin:manage'], + 'mobile-pwa': ['agent:write', 'backup:write', 'policy:write', 'admin:manage'], +}; + +const CLIENT_MODE_CAPABILITIES: Record = { + 'desktop-remote': [ + 'board:read', + 'workspace:read', + 'task:read', + 'task:write', + 'comment:write', + 'workflow:execute', + 'notification:read', + 'notification:receive', + 'remote:sync', + 'agent:run:scoped', + 'desktop:remote', + ], + 'desktop-local': [ + 'board:read', + 'workspace:read', + 'task:read', + 'task:write', + 'comment:write', + 'workflow:execute', + 'notification:read', + 'notification:receive', + 'remote:sync', + 'agent:run:scoped', + 'desktop:local', + ], + cli: ['workspace:read', 'task:read', 'task:write', 'workflow:execute', 'agent:run:scoped'], + browser: ['board:read', 'workspace:read', 'task:read', 'comment:write', 'workflow:read'], + 'mobile-pwa': [ + 'board:read', + 'workspace:read', + 'task:read', + 'task:write', + 'comment:write', + 'workflow:read', + 'notification:read', + 'notification:receive', + 'remote:sync', + ], +}; + +export type ClientMode = (typeof CLIENT_MODES)[number]; + +export interface DevicePairingPayload { + code: string; + workspaceId: string; + deviceId: string; + clientId: string; + clientMode: ClientMode; + capabilities: string[]; + scopes: AuthPermission[]; + role: WorkspaceRole; + nonce: string; + signedAt: string; + signature: string; + expiresAt: string; +} + +export interface CreateDevicePairingResult { + pairing: DevicePairingCodeRecord; + code: string; + payload: DevicePairingPayload; + link: string; + pairingCode: DevicePairingCodeRecord; + pairingUrl: string; +} + +export interface ExchangeDevicePairingInput { + code: string; + nonce: string; + signedAt: string; + signature: string; + clientId?: string; + clientMode?: string; + capabilities?: string[]; + scopes?: AuthPermission[]; +} + +export interface ExchangeDevicePairingResult { + session: DeviceSessionRecord; + secret: string; + connectionState: DeviceConnectionState; +} + +export interface DeviceSessionValidationResult { + valid: boolean; + auth?: AuthContext; +} + +export interface DeviceSessionTestResult { + session: DeviceSessionRecord; + allowed: boolean; + reason: string; +} + +export interface DeviceSessionServiceOptions { + sessionRepository?: SqliteDeviceSessionRepository; + identityRepository?: SqliteIdentityRepository; + sqliteDatabase?: SqliteDatabase; + sqliteConnectionOptions?: SqliteConnectionOptions; + audit?: (event: AuditEvent) => Promise; + activity?: Pick; +} + +export class DeviceSessionService { + private readonly sessionRepository: SqliteDeviceSessionRepository; + private readonly identityRepository: SqliteIdentityRepository; + private readonly sqliteDatabase: SqliteDatabase | null = null; + private readonly ownsSqliteDatabase: boolean = false; + private readonly audit: (event: AuditEvent) => Promise; + private readonly activity: Pick; + + constructor(options: DeviceSessionServiceOptions = {}) { + if (options.sessionRepository && options.identityRepository) { + this.sessionRepository = options.sessionRepository; + this.identityRepository = options.identityRepository; + } else { + this.sqliteDatabase = + options.sqliteDatabase ?? new SqliteDatabase(options.sqliteConnectionOptions); + this.ownsSqliteDatabase = !options.sqliteDatabase; + this.sqliteDatabase.open(); + this.sessionRepository = + options.sessionRepository ?? new SqliteDeviceSessionRepository(this.sqliteDatabase); + this.identityRepository = + options.identityRepository ?? new SqliteIdentityRepository(this.sqliteDatabase); + } + + this.audit = options.audit ?? auditLog; + this.activity = + options.activity ?? + new ActivityService({ + storageType: process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file', + sqliteDatabase: options.sqliteDatabase, + }); + } + + listSessions(workspaceId: string, actor: IdentityActor): DeviceSessionRecord[] { + this.assertCanManageSessions(workspaceId, actor); + return this.sessionRepository.listSessionsByWorkspace(workspaceId); + } + + async createPairingCode( + input: { + workspaceId: string; + deviceName: string; + deviceType?: string; + deviceId?: string; + clientId?: string; + clientMode?: string; + capabilities?: string[]; + scopes?: AuthPermission[]; + role?: WorkspaceRole; + expiresAt?: string | null; + sessionExpiresAt?: string | null; + }, + actor: IdentityActor + ): Promise { + const membership = this.assertCanManageSessions(input.workspaceId, actor); + const clientMode = normalizeClientMode(input.clientMode ?? 'desktop-remote'); + const role = this.normalizeRole(input.role ?? membership.role, membership); + const capabilities = normalizeCapabilities(clientMode, input.capabilities); + const scopes = this.normalizeScopes( + input.scopes ?? defaultScopesForMode(clientMode), + actor, + role, + clientMode + ); + const code = generatePairingCode(); + const codeHash = hashPairingCode(code); + const now = new Date(); + const signedAt = now.toISOString(); + const expiresAt = + input.expiresAt ?? new Date(now.getTime() + PAIRING_CODE_TTL_MS).toISOString(); + const sessionExpiresAt = + input.sessionExpiresAt ?? new Date(now.getTime() + DEVICE_SESSION_TTL_MS).toISOString(); + const deviceId = input.deviceId?.trim() || `device_${randomUUID()}`; + const clientId = input.clientId?.trim() || `client_${randomUUID()}`; + const nonce = randomBytes(16).toString('base64url'); + const signature = signPairingPayload(codeHash, { + workspaceId: input.workspaceId, + deviceId, + clientId, + clientMode, + nonce, + signedAt, + }); + + if (Date.parse(expiresAt) <= Date.now()) { + throw new ValidationError('Pairing code expiration must be in the future'); + } + if (Date.parse(sessionExpiresAt) <= Date.now()) { + throw new ValidationError('Device session expiration must be in the future'); + } + + const pairing = this.sessionRepository.createPairingCode({ + workspaceId: input.workspaceId, + createdBy: actor.userId, + codePrefix: code.slice(0, TOKEN_PREFIX_LENGTH), + codeHash, + deviceName: input.deviceName, + deviceType: input.deviceType?.trim() || clientMode, + deviceId, + clientId, + clientMode, + capabilities, + scopes, + role, + nonce, + signedAt, + signature, + expiresAt, + sessionExpiresAt, + }); + const payload: DevicePairingPayload = { + code, + workspaceId: pairing.workspaceId, + deviceId, + clientId, + clientMode, + capabilities, + scopes, + role, + nonce, + signedAt, + signature, + expiresAt, + }; + + await this.recordDeviceChange('identity.device_pairing.create', actor, pairing.workspaceId, { + pairingId: pairing.id, + deviceId, + deviceName: pairing.deviceName, + clientId, + clientMode, + capabilities, + scopes, + role, + expiresAt, + sessionExpiresAt, + }); + + const link = `veritas://pair?payload=${base64UrlEncode(JSON.stringify(payload))}`; + return { + pairing, + code, + payload, + link, + pairingCode: pairing, + pairingUrl: link, + }; + } + + async exchangePairingCode( + input: ExchangeDevicePairingInput + ): Promise { + const codeHash = hashPairingCode(input.code); + const pairing = this.sessionRepository.getPairingCodeByHash(codeHash); + if (!pairing) { + throw new ValidationError('Pairing code is invalid'); + } + + if (pairing.attemptCount >= MAX_PAIRING_ATTEMPTS) { + await this.recordPairingFailure(pairing, 'pairing_attempt_limit_reached'); + throw new AppError(429, 'Pairing code is locked', 'RATE_LIMITED'); + } + + await this.assertPairingIsRedeemable(pairing); + await this.assertPairingPayload(pairing, input); + + const requestedMode = input.clientMode + ? normalizeClientMode(input.clientMode) + : pairing.clientMode; + if (requestedMode !== pairing.clientMode) { + await this.recordPairingFailure(pairing, 'client_mode_mismatch'); + throw new ForbiddenError('Pairing client mode does not match approved mode'); + } + + this.assertNoEscalation( + 'Device pairing scopes exceed the approved scopes', + input.scopes, + pairing.scopes + ); + this.assertNoEscalation( + 'Device pairing capabilities exceed the approved capabilities', + input.capabilities, + pairing.capabilities + ); + + const secret = generateDeviceSessionSecret(); + const session = this.sessionRepository.redeemPairingCode(pairing.id, { + workspaceId: pairing.workspaceId, + userId: pairing.createdBy, + deviceName: pairing.deviceName, + deviceType: pairing.deviceType, + deviceId: pairing.deviceId, + clientId: pairing.clientId, + clientMode: pairing.clientMode, + capabilities: pairing.capabilities, + scopes: pairing.scopes, + role: pairing.role, + tokenPrefix: secret.slice(0, TOKEN_PREFIX_LENGTH), + tokenHash: hashDeviceSessionSecret(secret), + nonce: pairing.nonce, + signedAt: pairing.signedAt, + signature: pairing.signature, + expiresAt: pairing.sessionExpiresAt, + }); + + if (!session) { + await this.recordPairingFailure(pairing, 'pairing_replay_detected'); + throw new ValidationError('Pairing code has already been used'); + } + + await this.recordDeviceChange( + 'identity.device_pairing.exchange', + { + userId: pairing.createdBy, + role: pairing.role, + displayName: pairing.deviceName, + permissions: pairing.scopes, + }, + pairing.workspaceId, + { + pairingId: pairing.id, + sessionId: session.id, + deviceId: session.deviceId, + deviceName: session.deviceName, + clientId: session.clientId, + clientMode: session.clientMode, + scopes: session.scopes, + } + ); + + return { session, secret, connectionState: session.connectionState }; + } + + async revokeSession( + sessionId: string, + actor: IdentityActor, + expectedWorkspaceId?: string + ): Promise { + const existing = this.sessionRepository.getSession(sessionId); + if (!existing) throw new NotFoundError('Device session not found'); + if (expectedWorkspaceId && existing.workspaceId !== expectedWorkspaceId) { + throw new NotFoundError('Device session not found'); + } + this.assertCanManageSessions(existing.workspaceId, actor); + + const session = this.sessionRepository.revokeSession(sessionId, actor.userId); + if (!session) throw new ValidationError('Device session cannot be revoked'); + + await this.recordDeviceChange('identity.device_session.revoke', actor, session.workspaceId, { + sessionId, + deviceId: session.deviceId, + deviceName: session.deviceName, + clientId: session.clientId, + clientMode: session.clientMode, + }); + + return session; + } + + testSession( + sessionId: string, + actor: IdentityActor, + expectedWorkspaceId?: string + ): DeviceSessionTestResult { + const session = this.sessionRepository.getSession(sessionId); + if (!session) throw new NotFoundError('Device session not found'); + if (expectedWorkspaceId && session.workspaceId !== expectedWorkspaceId) { + throw new NotFoundError('Device session not found'); + } + this.assertCanManageSessions(session.workspaceId, actor); + + if (session.revokedAt) { + return { session, allowed: false, reason: 'revoked' }; + } + if (Date.parse(session.expiresAt) <= Date.now()) { + this.sessionRepository.updateSessionState(session.id, 'expired', 'expired'); + return { + session: this.sessionRepository.getSession(session.id) ?? session, + allowed: false, + reason: 'expired', + }; + } + + this.sessionRepository.updateSessionState(session.id, 'reconnecting', 'manual_test'); + const updated = this.sessionRepository.getSession(session.id) ?? session; + return { session: updated, allowed: true, reason: 'session_ready_for_reconnect' }; + } + + validateSecret(secret: string, ipAddress?: string | null): DeviceSessionValidationResult { + if (!isDeviceSessionSecret(secret)) return { valid: false }; + + const session = this.sessionRepository.getSessionForAuthByHash(hashDeviceSessionSecret(secret)); + if (!session) return { valid: false }; + + if (session.revokedAt) { + this.sessionRepository.recordAuthFailure(session.id, 'revoked', 'revoked'); + return { valid: false }; + } + if (Date.parse(session.expiresAt) <= Date.now()) { + this.sessionRepository.recordAuthFailure(session.id, 'expired', 'expired'); + return { valid: false }; + } + if (session.userDisabledAt) { + this.sessionRepository.recordAuthFailure(session.id, 'auth_failed', 'user_disabled'); + return { valid: false }; + } + if (session.workspaceArchivedAt) { + this.sessionRepository.recordAuthFailure(session.id, 'auth_failed', 'workspace_archived'); + return { valid: false }; + } + if (session.membershipStatus !== 'active' || session.membershipDisabledAt) { + this.sessionRepository.recordAuthFailure(session.id, 'auth_failed', 'membership_inactive'); + return { valid: false }; + } + + const currentRole = session.membershipRole ?? session.role; + const roleAllowed = new Set(WORKSPACE_ROLE_PERMISSIONS[currentRole] ?? []); + const effectiveScopes = session.scopes.filter((scope) => roleAllowed.has(scope)); + if (effectiveScopes.length === 0) { + this.sessionRepository.recordAuthFailure( + session.id, + 'auth_failed', + 'role_downgraded_no_scopes' + ); + return { valid: false }; + } + + const degraded = + currentRole !== session.role || effectiveScopes.length !== session.scopes.length + ? 'role_downgraded' + : null; + this.sessionRepository.recordSessionUse(session.id, ipAddress); + this.sessionRepository.recordDegradedSession(session.id, degraded); + + return { + valid: true, + auth: { + role: roleForScopes(effectiveScopes), + keyName: session.deviceName, + isLocalhost: false, + userId: session.userId, + workspaceId: session.workspaceId, + actorType: 'device', + authMethod: 'device-session', + tokenName: session.deviceName, + permissions: effectiveScopes, + deviceSessionId: session.id, + deviceId: session.deviceId, + clientId: session.clientId, + clientMode: session.clientMode, + capabilities: session.capabilities, + degradedReason: degraded, + }, + }; + } + + close(): void { + if (this.ownsSqliteDatabase) { + this.sqliteDatabase?.close(); + } + } + + private assertCanManageSessions(workspaceId: string, actor: IdentityActor): WorkspaceMembership { + this.identityRepository.ensureLocalOwner(); + const membership = this.identityRepository.getMembership(workspaceId, actor.userId); + if (!membership || membership.status !== 'active' || membership.disabledAt) { + throw new ForbiddenError('No active membership for workspace'); + } + if (!MANAGER_ROLES.has(membership.role)) { + throw new ForbiddenError('Only workspace owners and admins can manage device sessions'); + } + return membership; + } + + private normalizeRole(role: WorkspaceRole, actorMembership: WorkspaceMembership): WorkspaceRole { + if (role === 'owner' && actorMembership.role !== 'owner') { + throw new ForbiddenError('Only workspace owners can approve owner device sessions'); + } + return role; + } + + private normalizeScopes( + scopes: AuthPermission[], + actor: IdentityActor, + role: WorkspaceRole, + clientMode: ClientMode + ): AuthPermission[] { + const uniqueScopes = [...new Set(scopes)]; + if (uniqueScopes.length === 0) { + throw new ValidationError('At least one device session scope is required'); + } + + const invalid = uniqueScopes.filter((scope) => !SCOPED_PERMISSION_SET.has(scope)); + if (invalid.length > 0) { + throw new ValidationError('Invalid device session scope', { invalid }); + } + + const rolePermissions = new Set(WORKSPACE_ROLE_PERMISSIONS[role] ?? []); + const roleDenied = uniqueScopes.filter((scope) => !rolePermissions.has(scope)); + if (roleDenied.length > 0) { + throw new ForbiddenError('Device session scopes exceed the approved role', { + denied: roleDenied, + }); + } + + const modeDeniedSet = new Set(CLIENT_MODE_SCOPE_DENY[clientMode]); + const modeDenied = uniqueScopes.filter((scope) => modeDeniedSet.has(scope)); + if (modeDenied.length > 0) { + throw new ForbiddenError('Device session scopes exceed the client-mode policy', { + denied: modeDenied, + }); + } + + const actorPermissions = actor.permissions ?? []; + const actorHasAll = actorPermissions.includes('*') || actor.role === 'owner'; + if (!actorHasAll) { + const denied = uniqueScopes.filter((scope) => !actorPermissions.includes(scope)); + if (denied.length > 0) { + throw new ForbiddenError('Device session scopes exceed the current actor permissions', { + denied, + }); + } + } + + return uniqueScopes; + } + + private async assertPairingIsRedeemable(pairing: DevicePairingCodeRecord): Promise { + if (pairing.revokedAt) { + await this.recordPairingFailure(pairing, 'pairing_revoked'); + throw new ValidationError('Pairing code has been revoked'); + } + if (pairing.usedAt) { + await this.recordPairingFailure(pairing, 'pairing_replay_detected'); + throw new ValidationError('Pairing code has already been used'); + } + if (Date.parse(pairing.expiresAt) <= Date.now()) { + await this.recordPairingFailure(pairing, 'pairing_expired'); + throw new ValidationError('Pairing code has expired'); + } + } + + private async assertPairingPayload( + pairing: DevicePairingCodeRecord, + input: ExchangeDevicePairingInput + ): Promise { + const signedAtMs = Date.parse(input.signedAt); + const now = Date.now(); + if (!Number.isFinite(signedAtMs)) { + throw new ValidationError('Pairing payload signed timestamp is invalid'); + } + if (signedAtMs < now - PAIRING_PAYLOAD_MAX_AGE_MS || signedAtMs > now + CLOCK_SKEW_MS) { + await this.recordPairingFailure(pairing, 'stale_pairing_payload'); + throw new ValidationError('Pairing payload is stale'); + } + if (input.nonce !== pairing.nonce) { + await this.recordPairingFailure(pairing, 'nonce_mismatch'); + throw new ValidationError('Pairing nonce does not match'); + } + if (input.signedAt !== pairing.signedAt) { + await this.recordPairingFailure(pairing, 'signed_timestamp_mismatch'); + throw new ValidationError('Pairing signed timestamp does not match'); + } + const expectedSignature = signPairingPayload(hashPairingCode(input.code), { + workspaceId: pairing.workspaceId, + deviceId: pairing.deviceId, + clientId: pairing.clientId, + clientMode: pairing.clientMode, + nonce: pairing.nonce, + signedAt: pairing.signedAt, + }); + if (input.signature !== expectedSignature || input.signature !== pairing.signature) { + await this.recordPairingFailure(pairing, 'signature_mismatch'); + throw new ValidationError('Pairing signature does not match'); + } + if (input.clientId && input.clientId !== pairing.clientId) { + await this.recordPairingFailure(pairing, 'client_id_mismatch'); + throw new ForbiddenError('Pairing client id does not match approved client'); + } + } + + private assertNoEscalation( + message: string, + requested: T[] | undefined, + approved: readonly T[] + ): void { + if (!requested) return; + const approvedSet = new Set(approved); + const denied = [...new Set(requested)].filter((item) => !approvedSet.has(item)); + if (denied.length > 0) { + throw new ForbiddenError(message, { denied }); + } + } + + private async recordPairingFailure( + pairing: DevicePairingCodeRecord, + reason: string + ): Promise { + const updated = this.sessionRepository.recordPairingAttempt(pairing.id) ?? pairing; + await this.audit({ + action: 'identity.device_pairing.failed', + actor: pairing.deviceName, + resource: pairing.workspaceId, + details: { + pairingId: pairing.id, + deviceId: pairing.deviceId, + clientId: pairing.clientId, + clientMode: pairing.clientMode, + reason, + attemptCount: updated.attemptCount, + }, + }); + } + + private async recordDeviceChange( + action: string, + actor: IdentityActor, + workspaceId: string, + details: Record + ): Promise { + await this.audit({ + action, + actor: actor.displayName || actor.userId, + resource: workspaceId, + details, + }); + + await this.activity.logActivity( + 'membership_updated', + `workspace:${workspaceId}`, + `Workspace ${workspaceId}`, + { + action, + actor: actor.userId, + ...details, + } + ); + } +} + +let deviceSessionService: DeviceSessionService | null = null; +let deviceSessionServicePath: string | null = null; + +export function getDeviceSessionService(): DeviceSessionService { + const databasePath = resolveSqliteDatabasePath(); + if (!deviceSessionService || deviceSessionServicePath !== databasePath) { + deviceSessionService?.close(); + deviceSessionService = new DeviceSessionService({ sqliteConnectionOptions: { databasePath } }); + deviceSessionServicePath = databasePath; + } + return deviceSessionService; +} + +export function resetDeviceSessionServiceForTests(): void { + deviceSessionService?.close(); + deviceSessionService = null; + deviceSessionServicePath = null; +} + +export function validateDeviceSessionSecret( + secret: string, + ipAddress?: string | null +): DeviceSessionValidationResult { + if (!isDeviceSessionSecret(secret)) return { valid: false }; + return getDeviceSessionService().validateSecret(secret, ipAddress); +} + +export function generateDeviceSessionSecret(): string { + return `${DEVICE_SESSION_SECRET_PREFIX}${randomBytes(32).toString('base64url')}`; +} + +export function hashDeviceSessionSecret(secret: string): string { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +export function isDeviceSessionSecret(secret: string): boolean { + return secret.startsWith(DEVICE_SESSION_SECRET_PREFIX); +} + +export function hashPairingCode(code: string): string { + return createHash('sha256').update(normalizePairingCode(code), 'utf8').digest('hex'); +} + +function generatePairingCode(): string { + return `${PAIRING_CODE_PREFIX}${randomBytes(18).toString('base64url')}`; +} + +function normalizePairingCode(code: string): string { + return code.trim(); +} + +function normalizeClientMode(value: string): ClientMode { + if (!CLIENT_MODE_SET.has(value)) { + throw new ValidationError('Invalid device client mode', { clientMode: value }); + } + return value as ClientMode; +} + +function normalizeCapabilities(clientMode: ClientMode, capabilities?: string[]): string[] { + const requested = capabilities?.length + ? [...new Set(capabilities)] + : defaultCapabilities(clientMode); + const allowed = new Set(CLIENT_MODE_CAPABILITIES[clientMode]); + const denied = requested.filter((capability) => !allowed.has(capability)); + if (denied.length > 0) { + throw new ForbiddenError('Device capabilities exceed the client-mode policy', { denied }); + } + return requested; +} + +function defaultCapabilities(clientMode: ClientMode): string[] { + return [...CLIENT_MODE_CAPABILITIES[clientMode]].slice(0, 4); +} + +function defaultScopesForMode(clientMode: ClientMode): AuthPermission[] { + if (clientMode === 'mobile-pwa' || clientMode === 'browser') { + return ['workspace:read', 'task:read', 'comment:write']; + } + return ['workspace:read', 'task:read', 'task:write', 'workflow:execute']; +} + +function signPairingPayload( + codeHash: string, + input: { + workspaceId: string; + deviceId: string; + clientId: string; + clientMode: string; + nonce: string; + signedAt: string; + } +): string { + return createHmac('sha256', codeHash) + .update( + [ + input.workspaceId, + input.deviceId, + input.clientId, + input.clientMode, + input.nonce, + input.signedAt, + ].join(':') + ) + .digest('base64url'); +} + +function roleForScopes(scopes: readonly AuthPermission[]): AuthRole { + return scopes.some((scope) => WRITE_PERMISSIONS.has(scope)) ? 'agent' : 'read-only'; +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url'); +} diff --git a/server/src/storage/index.ts b/server/src/storage/index.ts index dd79ce8e..4032dc43 100644 --- a/server/src/storage/index.ts +++ b/server/src/storage/index.ts @@ -87,6 +87,12 @@ export type { WorkspaceMembership, WorkspaceRole, } from './sqlite/identity-repository.js'; +export { SqliteDeviceSessionRepository } from './sqlite/device-session-repository.js'; +export type { + DeviceConnectionState, + DevicePairingCodeRecord, + DeviceSessionRecord, +} from './sqlite/device-session-repository.js'; // --------------------------------------------------------------------------- // Supported backend types (extend this union as new backends are added) diff --git a/server/src/storage/sqlite/device-session-repository.ts b/server/src/storage/sqlite/device-session-repository.ts new file mode 100644 index 00000000..e70b280d --- /dev/null +++ b/server/src/storage/sqlite/device-session-repository.ts @@ -0,0 +1,580 @@ +import { randomUUID } from 'node:crypto'; +import type { AuthPermission } from '../../middleware/auth.js'; +import type { SqliteDatabase } from './database.js'; +import type { WorkspaceRole } from './identity-repository.js'; + +export type DeviceConnectionState = + | 'pairing' + | 'connected' + | 'reconnecting' + | 'auth_failed' + | 'unreachable' + | 'revoked' + | 'expired'; + +export interface DevicePairingCodeRecord { + id: string; + workspaceId: string; + createdBy: string; + codePrefix: string; + deviceName: string; + deviceType: string; + deviceId: string; + clientId: string; + clientMode: string; + capabilities: string[]; + scopes: AuthPermission[]; + role: WorkspaceRole; + nonce: string; + signedAt: string; + signature: string; + createdAt: string; + expiresAt: string; + sessionExpiresAt: string; + usedAt: string | null; + usedBy: string | null; + revokedAt: string | null; + attemptCount: number; + lastAttemptAt: string | null; +} + +export interface DeviceSessionRecord { + id: string; + workspaceId: string; + userId: string; + deviceName: string; + deviceType: string; + deviceId: string; + clientId: string; + clientMode: string; + capabilities: string[]; + scopes: AuthPermission[]; + role: WorkspaceRole; + tokenPrefix: string; + nonce: string; + signedAt: string; + signature: string; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + revokedBy: string | null; + lastSeenAt: string | null; + lastSeenIp: string | null; + connectionState: DeviceConnectionState; + stateReason: string | null; + lastAuthFailure: string | null; + degradedReason: string | null; +} + +export interface DeviceSessionAuthRecord extends DeviceSessionRecord { + tokenHash: string; + userDisabledAt: string | null; + membershipRole: WorkspaceRole | null; + membershipStatus: string | null; + membershipDisabledAt: string | null; + workspaceArchivedAt: string | null; +} + +export interface CreateDevicePairingCodeInput { + workspaceId: string; + createdBy: string; + codePrefix: string; + codeHash: string; + deviceName: string; + deviceType: string; + deviceId: string; + clientId: string; + clientMode: string; + capabilities: string[]; + scopes: AuthPermission[]; + role: WorkspaceRole; + nonce: string; + signedAt: string; + signature: string; + expiresAt: string; + sessionExpiresAt: string; +} + +export interface CreateDeviceSessionInput { + workspaceId: string; + userId: string; + deviceName: string; + deviceType: string; + deviceId: string; + clientId: string; + clientMode: string; + capabilities: string[]; + scopes: AuthPermission[]; + role: WorkspaceRole; + tokenPrefix: string; + tokenHash: string; + nonce: string; + signedAt: string; + signature: string; + expiresAt: string; +} + +interface DevicePairingCodeRow { + id: string; + workspace_id: string; + created_by: string; + code_prefix: string; + code_hash: string; + device_name: string; + device_type: string; + device_id: string; + client_id: string; + client_mode: string; + capabilities_json: string; + scopes_json: string; + role: WorkspaceRole; + nonce: string; + signed_at: string; + signature: string; + created_at: string; + expires_at: string; + session_expires_at: string; + used_at: string | null; + used_by: string | null; + revoked_at: string | null; + attempt_count: number; + last_attempt_at: string | null; +} + +interface DeviceSessionRow { + id: string; + workspace_id: string; + user_id: string; + device_name: string; + device_type: string; + device_id: string; + client_id: string; + client_mode: string; + capabilities_json: string; + scopes_json: string; + role: WorkspaceRole; + token_prefix: string; + token_hash: string; + nonce: string; + signed_at: string; + signature: string; + created_at: string; + expires_at: string; + revoked_at: string | null; + revoked_by: string | null; + last_seen_at: string | null; + last_seen_ip: string | null; + connection_state: DeviceConnectionState; + state_reason: string | null; + last_auth_failure: string | null; + degraded_reason: string | null; + user_disabled_at?: string | null; + membership_role?: WorkspaceRole | null; + membership_status?: string | null; + membership_disabled_at?: string | null; + workspace_archived_at?: string | null; +} + +export class SqliteDeviceSessionRepository { + constructor(private readonly database: SqliteDatabase) {} + + createPairingCode(input: CreateDevicePairingCodeInput): DevicePairingCodeRecord { + const id = `pair_${randomUUID()}`; + const now = new Date().toISOString(); + + this.database + .getConnection() + .prepare( + ` + INSERT INTO device_pairing_codes ( + id, + workspace_id, + created_by, + code_prefix, + code_hash, + device_name, + device_type, + device_id, + client_id, + client_mode, + capabilities_json, + scopes_json, + role, + nonce, + signed_at, + signature, + created_at, + expires_at, + session_expires_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ) + .run( + id, + input.workspaceId, + input.createdBy, + input.codePrefix, + input.codeHash, + input.deviceName.trim(), + input.deviceType, + input.deviceId, + input.clientId.trim(), + input.clientMode, + JSON.stringify(input.capabilities), + JSON.stringify(input.scopes), + input.role, + input.nonce, + input.signedAt, + input.signature, + now, + input.expiresAt, + input.sessionExpiresAt + ); + + return requireValue(this.getPairingCode(id), 'Pairing code was not created'); + } + + getPairingCode(id: string): DevicePairingCodeRecord | null { + const row = this.database + .getConnection() + .prepare('SELECT * FROM device_pairing_codes WHERE id = ?') + .get(id) as DevicePairingCodeRow | undefined; + + return row ? mapPairingCode(row) : null; + } + + getPairingCodeByHash(codeHash: string): DevicePairingCodeRecord | null { + const row = this.database + .getConnection() + .prepare('SELECT * FROM device_pairing_codes WHERE code_hash = ?') + .get(codeHash) as DevicePairingCodeRow | undefined; + + return row ? mapPairingCode(row) : null; + } + + recordPairingAttempt(id: string): DevicePairingCodeRecord | null { + const now = new Date().toISOString(); + this.database + .getConnection() + .prepare( + ` + UPDATE device_pairing_codes + SET attempt_count = attempt_count + 1, + last_attempt_at = ? + WHERE id = ? + ` + ) + .run(now, id); + + return this.getPairingCode(id); + } + + redeemPairingCode( + pairingCodeId: string, + input: CreateDeviceSessionInput + ): DeviceSessionRecord | null { + const db = this.database.getConnection(); + const id = `devsess_${randomUUID()}`; + const now = new Date().toISOString(); + + try { + db.exec('BEGIN IMMEDIATE;'); + const markUsed = db + .prepare( + ` + UPDATE device_pairing_codes + SET used_at = ?, used_by = ? + WHERE id = ? + AND used_at IS NULL + AND revoked_at IS NULL + ` + ) + .run(now, input.userId, pairingCodeId); + + if (markUsed.changes === 0) { + db.exec('ROLLBACK;'); + return null; + } + + db.prepare( + ` + INSERT INTO device_sessions ( + id, + workspace_id, + user_id, + device_name, + device_type, + device_id, + client_id, + client_mode, + capabilities_json, + scopes_json, + role, + token_prefix, + token_hash, + nonce, + signed_at, + signature, + created_at, + expires_at, + connection_state, + state_reason + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'connected', 'paired') + ` + ).run( + id, + input.workspaceId, + input.userId, + input.deviceName.trim(), + input.deviceType, + input.deviceId, + input.clientId.trim(), + input.clientMode, + JSON.stringify(input.capabilities), + JSON.stringify(input.scopes), + input.role, + input.tokenPrefix, + input.tokenHash, + input.nonce, + input.signedAt, + input.signature, + now, + input.expiresAt + ); + + db.exec('COMMIT;'); + } catch (error) { + try { + db.exec('ROLLBACK;'); + } catch { + // Preserve the original write error. + } + throw error; + } + + return this.getSession(id); + } + + listSessionsByWorkspace(workspaceId: string): DeviceSessionRecord[] { + const rows = this.database + .getConnection() + .prepare( + ` + SELECT * + FROM device_sessions + WHERE workspace_id = ? + ORDER BY created_at DESC + ` + ) + .all(workspaceId) as unknown as DeviceSessionRow[]; + + return rows.map(mapDeviceSession); + } + + getSession(id: string): DeviceSessionRecord | null { + const row = this.database + .getConnection() + .prepare('SELECT * FROM device_sessions WHERE id = ?') + .get(id) as DeviceSessionRow | undefined; + + return row ? mapDeviceSession(row) : null; + } + + getSessionForAuthByHash(tokenHash: string): DeviceSessionAuthRecord | null { + const row = this.database + .getConnection() + .prepare( + ` + SELECT + s.*, + u.disabled_at AS user_disabled_at, + m.role AS membership_role, + m.status AS membership_status, + m.disabled_at AS membership_disabled_at, + w.archived_at AS workspace_archived_at + FROM device_sessions s + JOIN users u ON u.id = s.user_id + JOIN workspaces w ON w.id = s.workspace_id + LEFT JOIN workspace_memberships m + ON m.workspace_id = s.workspace_id + AND m.user_id = s.user_id + WHERE s.token_hash = ? + ` + ) + .get(tokenHash) as DeviceSessionRow | undefined; + + return row + ? { + ...mapDeviceSession(row), + tokenHash: row.token_hash, + userDisabledAt: row.user_disabled_at ?? null, + membershipRole: row.membership_role ?? null, + membershipStatus: row.membership_status ?? null, + membershipDisabledAt: row.membership_disabled_at ?? null, + workspaceArchivedAt: row.workspace_archived_at ?? null, + } + : null; + } + + revokeSession(id: string, revokedBy: string): DeviceSessionRecord | null { + const now = new Date().toISOString(); + const result = this.database + .getConnection() + .prepare( + ` + UPDATE device_sessions + SET revoked_at = ?, + revoked_by = ?, + connection_state = 'revoked', + state_reason = 'revoked_by_user' + WHERE id = ? + AND revoked_at IS NULL + ` + ) + .run(now, revokedBy, id); + + return result.changes > 0 ? this.getSession(id) : null; + } + + recordSessionUse(id: string, ipAddress?: string | null): void { + this.database + .getConnection() + .prepare( + ` + UPDATE device_sessions + SET last_seen_at = ?, + last_seen_ip = ?, + connection_state = 'connected', + state_reason = 'validated', + last_auth_failure = NULL + WHERE id = ? + ` + ) + .run(new Date().toISOString(), ipAddress ?? null, id); + } + + updateSessionState(id: string, state: DeviceConnectionState, reason?: string | null): void { + this.database + .getConnection() + .prepare( + ` + UPDATE device_sessions + SET connection_state = ?, + state_reason = ? + WHERE id = ? + ` + ) + .run(state, reason ?? null, id); + } + + recordAuthFailure(id: string, state: DeviceConnectionState, reason: string): void { + this.database + .getConnection() + .prepare( + ` + UPDATE device_sessions + SET connection_state = ?, + state_reason = ?, + last_auth_failure = ? + WHERE id = ? + ` + ) + .run(state, reason, reason, id); + } + + recordDegradedSession(id: string, reason: string | null): void { + this.database + .getConnection() + .prepare( + ` + UPDATE device_sessions + SET degraded_reason = ?, + state_reason = COALESCE(?, state_reason) + WHERE id = ? + ` + ) + .run(reason, reason, id); + } +} + +function mapPairingCode(row: DevicePairingCodeRow): DevicePairingCodeRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + createdBy: row.created_by, + codePrefix: row.code_prefix, + deviceName: row.device_name, + deviceType: row.device_type, + deviceId: row.device_id, + clientId: row.client_id, + clientMode: row.client_mode, + capabilities: parseStringArray(row.capabilities_json), + scopes: parseScopes(row.scopes_json), + role: row.role, + nonce: row.nonce, + signedAt: row.signed_at, + signature: row.signature, + createdAt: row.created_at, + expiresAt: row.expires_at, + sessionExpiresAt: row.session_expires_at, + usedAt: row.used_at, + usedBy: row.used_by, + revokedAt: row.revoked_at, + attemptCount: row.attempt_count, + lastAttemptAt: row.last_attempt_at, + }; +} + +function mapDeviceSession(row: DeviceSessionRow): DeviceSessionRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + userId: row.user_id, + deviceName: row.device_name, + deviceType: row.device_type, + deviceId: row.device_id, + clientId: row.client_id, + clientMode: row.client_mode, + capabilities: parseStringArray(row.capabilities_json), + scopes: parseScopes(row.scopes_json), + role: row.role, + tokenPrefix: row.token_prefix, + nonce: row.nonce, + signedAt: row.signed_at, + signature: row.signature, + createdAt: row.created_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + revokedBy: row.revoked_by, + lastSeenAt: row.last_seen_at, + lastSeenIp: row.last_seen_ip, + connectionState: deriveConnectionState(row), + stateReason: row.state_reason, + lastAuthFailure: row.last_auth_failure, + degradedReason: row.degraded_reason, + }; +} + +function deriveConnectionState(row: DeviceSessionRow): DeviceConnectionState { + if (row.revoked_at) return 'revoked'; + if (Date.parse(row.expires_at) <= Date.now()) return 'expired'; + return row.connection_state; +} + +function parseStringArray(value: string): string[] { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []; +} + +function parseScopes(value: string): AuthPermission[] { + return parseStringArray(value) as AuthPermission[]; +} + +function requireValue(value: T | null | undefined, message: string): T { + if (value === null || value === undefined) { + throw new Error(message); + } + return value; +} diff --git a/server/src/storage/sqlite/migrations.ts b/server/src/storage/sqlite/migrations.ts index d3af8071..45cd5ed4 100644 --- a/server/src/storage/sqlite/migrations.ts +++ b/server/src/storage/sqlite/migrations.ts @@ -979,6 +979,105 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [ ON api_tokens(workspace_id, revoked_at, expires_at); `, }, + { + version: 16, + name: '0016_device_pairing_sessions', + up: ` + CREATE TABLE device_pairing_codes ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + created_by TEXT NOT NULL REFERENCES users(id), + code_prefix TEXT NOT NULL, + code_hash TEXT NOT NULL UNIQUE, + device_name TEXT NOT NULL, + device_type TEXT NOT NULL, + device_id TEXT NOT NULL, + client_id TEXT NOT NULL, + client_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + scopes_json TEXT NOT NULL, + role TEXT NOT NULL CHECK ( + role IN ('owner', 'admin', 'member', 'reviewer', 'read-only', 'agent') + ), + nonce TEXT NOT NULL UNIQUE, + signed_at TEXT NOT NULL, + signature TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + session_expires_at TEXT NOT NULL, + used_at TEXT, + used_by TEXT REFERENCES users(id), + revoked_at TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_device_pairing_codes_workspace_created + ON device_pairing_codes(workspace_id, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_device_pairing_codes_hash + ON device_pairing_codes(code_hash); + + CREATE INDEX IF NOT EXISTS idx_device_pairing_codes_active + ON device_pairing_codes(workspace_id, used_at, revoked_at, expires_at); + + CREATE TABLE device_sessions ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_name TEXT NOT NULL, + device_type TEXT NOT NULL, + device_id TEXT NOT NULL, + client_id TEXT NOT NULL, + client_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + scopes_json TEXT NOT NULL, + role TEXT NOT NULL CHECK ( + role IN ('owner', 'admin', 'member', 'reviewer', 'read-only', 'agent') + ), + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + nonce TEXT NOT NULL UNIQUE, + signed_at TEXT NOT NULL, + signature TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + revoked_by TEXT REFERENCES users(id), + last_seen_at TEXT, + last_seen_ip TEXT, + connection_state TEXT NOT NULL DEFAULT 'pairing' CHECK ( + connection_state IN ( + 'pairing', + 'connected', + 'reconnecting', + 'auth_failed', + 'unreachable', + 'revoked', + 'expired' + ) + ), + state_reason TEXT, + last_auth_failure TEXT, + degraded_reason TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_device_sessions_workspace_created + ON device_sessions(workspace_id, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_device_sessions_hash + ON device_sessions(token_hash); + + CREATE INDEX IF NOT EXISTS idx_device_sessions_client + ON device_sessions(workspace_id, client_id, revoked_at); + + CREATE INDEX IF NOT EXISTS idx_device_sessions_device + ON device_sessions(workspace_id, device_id, revoked_at); + + CREATE INDEX IF NOT EXISTS idx_device_sessions_active + ON device_sessions(workspace_id, revoked_at, expires_at); + `, + }, ]; export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] { diff --git a/shared/src/utils/api-permissions.ts b/shared/src/utils/api-permissions.ts index 6115d7a4..c92bd429 100644 --- a/shared/src/utils/api-permissions.ts +++ b/shared/src/utils/api-permissions.ts @@ -1,6 +1,11 @@ export type ClientAuthRole = 'admin' | 'read-only' | 'agent'; -export type ClientAuthMethod = 'disabled' | 'session' | 'api-key' | 'localhost-bypass'; -export type ClientAuthActorType = 'user' | 'agent' | 'service' | 'localhost-bypass'; +export type ClientAuthMethod = + | 'disabled' + | 'session' + | 'api-key' + | 'device-session' + | 'localhost-bypass'; +export type ClientAuthActorType = 'user' | 'agent' | 'service' | 'device' | 'localhost-bypass'; export type ClientAuthPermission = | '*' | 'workspace:read' @@ -35,6 +40,12 @@ export interface ClientAuthContext { authMethod?: ClientAuthMethod; tokenName?: string; permissions?: ClientAuthPermission[]; + deviceSessionId?: string; + deviceId?: string; + clientId?: string; + clientMode?: string; + capabilities?: string[]; + degradedReason?: string | null; } export interface ApiPermissionRequirement { diff --git a/web/src/__tests__/desktop-onboarding.test.tsx b/web/src/__tests__/desktop-onboarding.test.tsx index 47f53636..2f7208f5 100644 --- a/web/src/__tests__/desktop-onboarding.test.tsx +++ b/web/src/__tests__/desktop-onboarding.test.tsx @@ -85,6 +85,7 @@ describe('desktop onboarding', () => { mode: 'remote', serverUrl: 'https://remote.example', serverToken: 'vk_pat_secret', + pairingPayload: undefined, }); }); diff --git a/web/src/__tests__/multi-user-tab.test.tsx b/web/src/__tests__/multi-user-tab.test.tsx index 3017968f..2d74053d 100644 --- a/web/src/__tests__/multi-user-tab.test.tsx +++ b/web/src/__tests__/multi-user-tab.test.tsx @@ -132,6 +132,36 @@ describe('MultiUserTab', () => { }, ]); } + if (url.endsWith('/api/identity/workspaces/local/device-sessions')) { + return jsonResponse([ + { + id: 'devsess_phone', + workspaceId: 'local', + userId: 'local-user', + deviceName: 'Brad phone', + deviceType: 'pwa', + deviceId: 'device_phone', + clientId: 'mobile-client-1', + clientMode: 'mobile-pwa', + capabilities: ['workspace:read', 'task:read'], + scopes: ['workspace:read', 'task:read'], + role: 'member', + tokenPrefix: 'vk_dev_abcd1234', + nonce: 'nonce', + signedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-12-31T00:00:00.000Z', + revokedAt: null, + revokedBy: null, + lastSeenAt: '2026-01-01T01:00:00.000Z', + lastSeenIp: '127.0.0.1', + connectionState: 'connected', + stateReason: null, + lastAuthFailure: null, + degradedReason: null, + }, + ]); + } return jsonResponse({ error: `Unexpected ${url}` }, 404); }) ); @@ -141,6 +171,7 @@ describe('MultiUserTab', () => { expect(await screen.findByText('Local Workspace')).toBeDefined(); expect(await screen.findByText('Local User')).toBeDefined(); expect(await screen.findByText('expired@example.com')).toBeDefined(); + expect(await screen.findByText('Brad phone')).toBeDefined(); expect(await screen.findByText('CLI worker')).toBeDefined(); expect(screen.getByText('Expired')).toBeDefined(); expect(screen.getByText('Active')).toBeDefined(); diff --git a/web/src/components/auth/DesktopOnboarding.tsx b/web/src/components/auth/DesktopOnboarding.tsx index f09e9dde..4999bb4d 100644 --- a/web/src/components/auth/DesktopOnboarding.tsx +++ b/web/src/components/auth/DesktopOnboarding.tsx @@ -60,6 +60,7 @@ interface DesktopBridgeApi { mode: 'local' | 'remote'; serverUrl?: string; serverToken?: string; + pairingPayload?: string; }): Promise; pickUploadFiles?(request: { purpose: 'backup-restore'; @@ -246,6 +247,7 @@ export function DesktopOnboardingPanel({ const [copiedDiagnostics, setCopiedDiagnostics] = useState(false); const [remoteUrl, setRemoteUrl] = useState(''); const [remoteToken, setRemoteToken] = useState(''); + const [remotePairingPayload, setRemotePairingPayload] = useState(''); const [remoteResult, setRemoteResult] = useState(null); const [remoteLoading, setRemoteLoading] = useState(false); const [restoreFiles, setRestoreFiles] = useState([]); @@ -275,6 +277,7 @@ export function DesktopOnboardingPanel({ mode: 'remote', serverUrl: remoteUrl, serverToken: remoteToken || undefined, + pairingPayload: remotePairingPayload || undefined, }) : await validateRemoteWithoutDesktop(remoteUrl); setRemoteResult(result); @@ -471,8 +474,8 @@ export function DesktopOnboardingPanel({ Remote validation

- Validate reachability now. Pairing, device sessions, and tunnels are completed by - the remote-security workstream. + Validate reachability with a trusted token or a one-time pairing link from the + remote server.

@@ -493,6 +496,13 @@ export function DesktopOnboardingPanel({ onChange={(event) => setRemoteToken(event.target.value)} placeholder="Optional scoped token" /> + setRemotePairingPayload(event.target.value)} + placeholder="Optional veritas://pair link or JSON payload" + />