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.
This commit is contained in:
Brad Groux 2026-06-03 02:02:22 -07:00 committed by GitHub
parent 847c9d1287
commit 58f39ea2b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3338 additions and 68 deletions

View file

@ -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' },
});

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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 <token>` | Browser sessions, UI login |
| **API Key** | `X-API-Key: <key>` | Agent integrations, scripts |
| **WS Query Param** | `ws://host:port/ws?api_key=<key>` | WebSocket connections |
| Method | Header / Param | Use Case |
| ---------------------- | --------------------------------- | --------------------------------- |
| **Bearer Token** (JWT) | `Authorization: Bearer <token>` | Browser sessions, UI login |
| **API Key** | `X-API-Key: <key>` | Agent integrations, scripts |
| **Device Session** | `Authorization: Bearer vk_dev_…` | Paired desktop/mobile/PWA clients |
| **WS Query Param** | `ws://host:port/ws?api_key=<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

View file

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

View file

@ -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<CreateDevicePairingResult> {
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();
}
});
});

View file

@ -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' });

View file

@ -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);

View file

@ -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();
}
});
});

View file

@ -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<AuthRole, readonly AuthPermission[]> = {
@ -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)

View file

@ -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<typeof redeemPairingCodeSchema>;
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:

View file

@ -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;
}

View file

@ -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<WorkspaceRole>(['owner', 'admin']);
const CLIENT_MODES = ['desktop-remote', 'desktop-local', 'mobile-pwa', 'browser', 'cli'] as const;
const CLIENT_MODE_SET = new Set<string>(CLIENT_MODES);
const SCOPED_PERMISSION_SET = new Set<AuthPermission>(SCOPED_API_TOKEN_PERMISSIONS);
const WRITE_PERMISSIONS = new Set<AuthPermission>([
'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<WorkspaceRole, readonly AuthPermission[]> = {
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<ClientMode, readonly AuthPermission[]> = {
'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<ClientMode, readonly string[]> = {
'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<void>;
activity?: Pick<ActivityService, 'logActivity'>;
}
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<void>;
private readonly activity: Pick<ActivityService, 'logActivity'>;
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<CreateDevicePairingResult> {
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<ExchangeDevicePairingResult> {
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<DeviceSessionRecord> {
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<void> {
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<void> {
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<T extends string>(
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<void> {
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<string, unknown>
): Promise<void> {
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');
}

View file

@ -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)

View file

@ -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<T>(value: T | null | undefined, message: string): T {
if (value === null || value === undefined) {
throw new Error(message);
}
return value;
}

View file

@ -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[] {

View file

@ -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 {

View file

@ -85,6 +85,7 @@ describe('desktop onboarding', () => {
mode: 'remote',
serverUrl: 'https://remote.example',
serverToken: 'vk_pat_secret',
pairingPayload: undefined,
});
});

View file

@ -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();

View file

@ -60,6 +60,7 @@ interface DesktopBridgeApi {
mode: 'local' | 'remote';
serverUrl?: string;
serverToken?: string;
pairingPayload?: string;
}): Promise<DesktopConnectionValidationResult>;
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<DesktopConnectionValidationResult | null>(null);
const [remoteLoading, setRemoteLoading] = useState(false);
const [restoreFiles, setRestoreFiles] = useState<DesktopSelectedFile[]>([]);
@ -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
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<div className="grid gap-3">
@ -493,6 +496,13 @@ export function DesktopOnboardingPanel({
onChange={(event) => setRemoteToken(event.target.value)}
placeholder="Optional scoped token"
/>
<PasswordInput
id="remote-pairing-payload"
label="Pairing link"
value={remotePairingPayload}
onChange={(event) => setRemotePairingPayload(event.target.value)}
placeholder="Optional veritas://pair link or JSON payload"
/>
<Button
type="button"
variant="outline"

View file

@ -8,8 +8,10 @@ import {
Clipboard,
KeyRound,
MailPlus,
QrCode,
RefreshCw,
Shield,
Smartphone,
Trash2,
Users,
type LucideIcon,
@ -20,19 +22,29 @@ import {
WORKSPACE_ROLES,
useCreateWorkspaceApiToken,
useCreateWorkspaceInvitation,
useCreateWorkspacePairingCode,
useIdentity,
useRemoveWorkspaceMember,
useRevokeWorkspaceApiToken,
useRevokeWorkspaceDeviceSession,
useRevokeWorkspaceInvitation,
useRotateWorkspaceApiToken,
useTestWorkspaceDeviceSession,
useUpdateWorkspaceMemberRole,
useWorkspaceApiTokens,
useWorkspaceDeviceSessions,
useWorkspaceInvitations,
useWorkspaceMembers,
type WorkspaceRole,
} from '@/hooks/useIdentity';
import type { ClientAuthPermission } from '@veritas-kanban/shared';
import type { ApiTokenSummary, WorkspaceInvitation, WorkspaceMembership } from '@/lib/api/identity';
import type {
ApiTokenSummary,
CreatePairingCodeResult,
DeviceSessionSummary,
WorkspaceInvitation,
WorkspaceMembership,
} from '@/lib/api/identity';
const ROLE_LABELS: Record<WorkspaceRole, string> = {
owner: 'Owner',
@ -43,6 +55,77 @@ const ROLE_LABELS: Record<WorkspaceRole, string> = {
agent: 'Agent',
};
const DEVICE_TYPE_OPTIONS = [
{ value: 'desktop', label: 'Desktop' },
{ value: 'mobile', label: 'Mobile' },
{ value: 'tablet', label: 'Tablet' },
{ value: 'browser', label: 'Browser' },
{ value: 'pwa', label: 'PWA' },
{ value: 'cli', label: 'CLI' },
];
const DEVICE_CLIENT_MODE_OPTIONS = [
{ value: 'mobile-pwa', label: 'Mobile PWA' },
{ value: 'browser', label: 'Browser' },
{ value: 'desktop-remote', label: 'Desktop remote' },
{ value: 'desktop-local', label: 'Desktop local' },
{ value: 'cli', label: 'CLI' },
];
const DEVICE_CAPABILITY_OPTIONS = [
{ value: 'workspace:read', label: 'Workspace read' },
{ value: 'task:read', label: 'Task read' },
{ value: 'task:write', label: 'Task write' },
{ value: 'comment:write', label: 'Comment write' },
{ value: 'workflow:read', label: 'Workflow read' },
{ value: 'workflow:execute', label: 'Workflow execute' },
{ value: 'notification:read', label: 'Notifications' },
{ value: 'agent:run:scoped', label: 'Scoped agent run' },
{ value: 'desktop:remote', label: 'Desktop remote' },
{ value: 'desktop:local', label: 'Desktop local' },
];
const DEVICE_CAPABILITIES_BY_MODE: Record<string, string[]> = {
'desktop-remote': [
'workspace:read',
'task:read',
'task:write',
'comment:write',
'workflow:execute',
'notification:read',
'agent:run:scoped',
'desktop:remote',
],
'desktop-local': [
'workspace:read',
'task:read',
'task:write',
'comment:write',
'workflow:execute',
'notification:read',
'agent:run:scoped',
'desktop:local',
],
cli: ['workspace:read', 'task:read', 'task:write', 'workflow:execute', 'agent:run:scoped'],
browser: ['workspace:read', 'task:read', 'comment:write', 'workflow:read'],
'mobile-pwa': [
'workspace:read',
'task:read',
'task:write',
'comment:write',
'workflow:read',
'notification:read',
],
};
const MOBILE_UNSAFE_SCOPES = new Set<ClientAuthPermission>([
'admin:manage',
'agent:write',
'settings:write',
'policy:write',
'backup:write',
]);
function formatDate(value: string | null | undefined) {
if (!value) return 'Never';
return new Intl.DateTimeFormat(undefined, {
@ -71,6 +154,25 @@ function tokenStatus(token: ApiTokenSummary) {
return { label: 'Active', color: 'green', variant: 'light' as const };
}
function deviceSessionStatus(session: DeviceSessionSummary) {
if (session.revokedAt || session.connectionState === 'revoked') {
return { label: 'Revoked', color: 'red', variant: 'light' as const };
}
if (Date.parse(session.expiresAt) <= Date.now() || session.connectionState === 'expired') {
return { label: 'Expired', color: 'gray', variant: 'outline' as const };
}
if (session.connectionState === 'auth_failed') {
return { label: 'Auth failed', color: 'red', variant: 'outline' as const };
}
if (session.connectionState === 'reconnecting') {
return { label: 'Reconnecting', color: 'yellow', variant: 'light' as const };
}
if (session.connectionState === 'unreachable') {
return { label: 'Unreachable', color: 'yellow', variant: 'outline' as const };
}
return { label: 'Connected', color: 'green', variant: 'light' as const };
}
function memberName(member: WorkspaceMembership) {
return member.user?.displayName ?? member.userId;
}
@ -133,18 +235,43 @@ export function MultiUserTab() {
'task:read',
]);
const [createdApiTokenSecret, setCreatedApiTokenSecret] = useState<string | null>(null);
const [deviceName, setDeviceName] = useState('');
const [deviceType, setDeviceType] = useState('pwa');
const [deviceClientMode, setDeviceClientMode] = useState('mobile-pwa');
const [deviceRole, setDeviceRole] = useState<WorkspaceRole>('member');
const [deviceSessionExpiresAt, setDeviceSessionExpiresAt] = useState('');
const [deviceScopes, setDeviceScopes] = useState<ClientAuthPermission[]>([
'workspace:read',
'task:read',
'task:write',
'comment:write',
]);
const [deviceCapabilities, setDeviceCapabilities] = useState<string[]>([
'workspace:read',
'task:read',
'task:write',
'comment:write',
]);
const [createdPairingCode, setCreatedPairingCode] = useState<CreatePairingCodeResult | null>(
null
);
const workspaceId = activeWorkspace?.id ?? null;
const canManageApiTokens = hasPermission('admin:manage');
const canManageDeviceSessions = hasPermission('admin:manage');
const membersQuery = useWorkspaceMembers(workspaceId);
const invitationsQuery = useWorkspaceInvitations(workspaceId, canManageMembers);
const apiTokensQuery = useWorkspaceApiTokens(workspaceId, canManageApiTokens);
const deviceSessionsQuery = useWorkspaceDeviceSessions(workspaceId, canManageDeviceSessions);
const createInvitation = useCreateWorkspaceInvitation(workspaceId);
const createApiToken = useCreateWorkspaceApiToken(workspaceId);
const createPairingCode = useCreateWorkspacePairingCode(workspaceId);
const updateMemberRole = useUpdateWorkspaceMemberRole(workspaceId);
const removeMember = useRemoveWorkspaceMember(workspaceId);
const revokeInvitation = useRevokeWorkspaceInvitation(workspaceId);
const revokeApiToken = useRevokeWorkspaceApiToken(workspaceId);
const revokeDeviceSession = useRevokeWorkspaceDeviceSession(workspaceId);
const testDeviceSession = useTestWorkspaceDeviceSession(workspaceId);
const rotateApiToken = useRotateWorkspaceApiToken(workspaceId);
const roleOptions = useMemo(
@ -170,10 +297,23 @@ export function MultiUserTab() {
const members = membersQuery.data ?? [];
const invitations = invitationsQuery.data ?? [];
const apiTokens = apiTokensQuery.data ?? [];
const deviceSessions = deviceSessionsQuery.data ?? [];
const selectableTokenScopes = useMemo(
() => SCOPED_API_TOKEN_PERMISSIONS.filter((permission) => hasPermission(permission)),
[hasPermission]
);
const selectableDeviceScopes = useMemo(
() =>
selectableTokenScopes.filter(
(permission) =>
deviceClientMode === 'desktop-remote' || !MOBILE_UNSAFE_SCOPES.has(permission)
),
[deviceClientMode, selectableTokenScopes]
);
const selectableDeviceCapabilities = useMemo(() => {
const allowed = new Set(DEVICE_CAPABILITIES_BY_MODE[deviceClientMode] ?? []);
return DEVICE_CAPABILITY_OPTIONS.filter((capability) => allowed.has(capability.value));
}, [deviceClientMode]);
const handleCreateInvitation = async (event: FormEvent) => {
event.preventDefault();
@ -246,6 +386,72 @@ export function MultiUserTab() {
toast({ title: 'API token copied', duration: 2500 });
};
const handleCreatePairingCode = async (event: FormEvent) => {
event.preventDefault();
if (!workspaceId) return;
setCreatedPairingCode(null);
try {
const result = await createPairingCode.mutateAsync({
deviceName: deviceName.trim(),
deviceType,
clientMode: deviceClientMode,
capabilities: deviceCapabilities,
scopes: deviceScopes,
role: deviceRole,
sessionExpiresAt: deviceSessionExpiresAt
? new Date(deviceSessionExpiresAt).toISOString()
: null,
});
setCreatedPairingCode(result);
setDeviceName('');
setDeviceSessionExpiresAt('');
toast({ title: 'Pairing code created', description: result.pairing.deviceName });
} catch (err) {
toast({
title: 'Pairing failed',
description: err instanceof Error ? err.message : 'Unable to create pairing code.',
duration: Infinity,
});
}
};
const handleDeviceModeChange = (mode: string) => {
setDeviceClientMode(mode);
setDeviceCapabilities((DEVICE_CAPABILITIES_BY_MODE[mode] ?? []).slice(0, 4));
if (mode !== 'desktop-remote') {
setDeviceScopes((current) => current.filter((scope) => !MOBILE_UNSAFE_SCOPES.has(scope)));
}
};
const handleToggleDeviceScope = (permission: ClientAuthPermission, checked: boolean) => {
setDeviceScopes((current) =>
checked
? [...new Set([...current, permission])]
: current.filter((scope) => scope !== permission)
);
};
const handleToggleDeviceCapability = (capability: string, checked: boolean) => {
setDeviceCapabilities((current) =>
checked
? [...new Set([...current, capability])]
: current.filter((item) => item !== capability)
);
};
const handleCopyPairingCode = async () => {
if (!createdPairingCode || !navigator.clipboard) return;
await navigator.clipboard.writeText(createdPairingCode.code);
toast({ title: 'Pairing code copied', duration: 2500 });
};
const handleCopyPairingLink = async () => {
if (!createdPairingCode || !navigator.clipboard) return;
await navigator.clipboard.writeText(createdPairingCode.link);
toast({ title: 'Pairing link copied', duration: 2500 });
};
const handleRoleChange = async (member: WorkspaceMembership, role: WorkspaceRole) => {
try {
await updateMemberRole.mutateAsync({ userId: member.userId, role });
@ -312,6 +518,35 @@ export function MultiUserTab() {
}
};
const handleRevokeDeviceSession = async (session: DeviceSessionSummary) => {
try {
await revokeDeviceSession.mutateAsync(session.id);
toast({ title: 'Device session revoked', description: session.deviceName });
} catch (err) {
toast({
title: 'Revoke failed',
description: err instanceof Error ? err.message : 'Unable to revoke device session.',
duration: Infinity,
});
}
};
const handleTestDeviceSession = async (session: DeviceSessionSummary) => {
try {
const result = await testDeviceSession.mutateAsync(session.id);
toast({
title: result.allowed ? 'Device session ready' : 'Device session blocked',
description: `${session.deviceName}: ${result.reason}`,
});
} catch (err) {
toast({
title: 'Device test failed',
description: err instanceof Error ? err.message : 'Unable to test device session.',
duration: Infinity,
});
}
};
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading workspace access...</div>;
}
@ -579,6 +814,279 @@ export function MultiUserTab() {
)}
</Section>
<Section title="Trusted Devices" icon={Smartphone}>
{!canManageDeviceSessions ? (
<PermissionEmptyState message="Owner or admin permission is required to pair and revoke trusted devices." />
) : (
<div className="space-y-3">
<form className="grid gap-3 rounded-md border p-3" onSubmit={handleCreatePairingCode}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<TextInput
id="device-name"
label="Device"
value={deviceName}
onChange={(event) => setDeviceName(event.target.value)}
placeholder="Brad's phone"
size="sm"
radius="md"
/>
<Select
data={DEVICE_TYPE_OPTIONS}
label="Type"
value={deviceType}
onChange={(value) => {
if (value) setDeviceType(value);
}}
aria-label="Device type"
size="sm"
radius="md"
/>
<Select
data={DEVICE_CLIENT_MODE_OPTIONS}
label="Mode"
value={deviceClientMode}
onChange={(value) => {
if (value) handleDeviceModeChange(value);
}}
aria-label="Device client mode"
size="sm"
radius="md"
/>
<Select
data={roleSelectData}
label="Role"
value={deviceRole}
onChange={(role) => {
if (role) setDeviceRole(role as WorkspaceRole);
}}
aria-label="Device role"
size="sm"
radius="md"
/>
</div>
<TextInput
id="device-session-expires"
type="datetime-local"
label="Session expires"
value={deviceSessionExpiresAt}
onChange={(event) => setDeviceSessionExpiresAt(event.target.value)}
size="sm"
radius="md"
/>
<div className="grid gap-2">
<div className="text-sm font-medium">Capabilities</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{selectableDeviceCapabilities.map((capability) => (
<div
key={capability.value}
className="flex items-center gap-2 rounded-md border px-2.5 py-2 text-xs"
>
<Checkbox
checked={deviceCapabilities.includes(capability.value)}
onChange={(event) =>
handleToggleDeviceCapability(
capability.value,
event.currentTarget.checked
)
}
label={<span>{capability.label}</span>}
radius="sm"
/>
</div>
))}
</div>
</div>
<div className="grid gap-2">
<div className="text-sm font-medium">Scopes</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{selectableDeviceScopes.map((permission) => (
<div
key={permission}
className="flex items-center gap-2 rounded-md border px-2.5 py-2 text-xs"
>
<Checkbox
checked={deviceScopes.includes(permission)}
onChange={(event) =>
handleToggleDeviceScope(permission, event.currentTarget.checked)
}
label={<span className="font-mono">{permissionLabel(permission)}</span>}
radius="sm"
/>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={
createPairingCode.isPending ||
deviceName.trim().length === 0 ||
deviceScopes.length === 0
}
className="w-full sm:w-auto"
radius="md"
leftSection={<QrCode className="h-4 w-4" aria-hidden="true" />}
>
Pair
</Button>
</div>
</form>
{createdPairingCode && (
<div className="grid gap-3 rounded-md border border-emerald-500/40 bg-emerald-500/5 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
One-time pairing code
</div>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<TextInput
value={createdPairingCode.code}
readOnly
classNames={{ input: 'font-mono text-sm tracking-normal' }}
size="sm"
radius="md"
aria-label="Pairing code"
/>
<ActionIcon
type="button"
variant="outline"
onClick={() => void handleCopyPairingCode()}
aria-label="Copy pairing code"
size="lg"
radius="md"
>
<Clipboard className="h-4 w-4" aria-hidden="true" />
</ActionIcon>
</div>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<TextInput
value={createdPairingCode.link}
readOnly
classNames={{ input: 'font-mono text-xs tracking-normal' }}
size="sm"
radius="md"
aria-label="Pairing link"
/>
<ActionIcon
type="button"
variant="outline"
onClick={() => void handleCopyPairingLink()}
aria-label="Copy pairing link"
size="lg"
radius="md"
>
<Clipboard className="h-4 w-4" aria-hidden="true" />
</ActionIcon>
</div>
<div className="text-xs text-muted-foreground">
Expires {formatDate(createdPairingCode.pairing.expiresAt)}
</div>
</div>
)}
{deviceSessionsQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading trusted devices...</div>
) : deviceSessions.length === 0 ? (
<PermissionEmptyState message="No trusted device sessions have been paired for this workspace." />
) : (
<div className="space-y-2">
{deviceSessions.map((session) => {
const status = deviceSessionStatus(session);
const canRevoke = status.label !== 'Revoked' && status.label !== 'Expired';
return (
<div key={session.id} className="rounded-md border p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{session.deviceName}</span>
<Badge variant={status.variant} color={status.color}>
{status.label}
</Badge>
<Badge variant="outline" color="gray">
{ROLE_LABELS[session.role]}
</Badge>
<Badge variant="outline" color="gray" className="font-mono">
{session.tokenPrefix}...
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{session.clientMode} · Last seen {formatDate(session.lastSeenAt)} ·
Expires {formatDate(session.expiresAt)}
</div>
<div className="text-xs text-muted-foreground">
Client {session.clientId} · Device {session.deviceId}
</div>
{(session.stateReason ||
session.degradedReason ||
session.lastAuthFailure) && (
<div className="text-xs text-muted-foreground">
{session.degradedReason ??
session.lastAuthFailure ??
session.stateReason}
</div>
)}
</div>
<div className="flex items-center gap-1">
<ActionIcon
type="button"
variant="subtle"
color="gray"
size="sm"
radius="md"
onClick={() => void handleTestDeviceSession(session)}
disabled={!canRevoke || testDeviceSession.isPending}
aria-label={`Test ${session.deviceName}`}
title="Test device session"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
</ActionIcon>
<ActionIcon
type="button"
variant="subtle"
color="red"
size="sm"
radius="md"
onClick={() => void handleRevokeDeviceSession(session)}
disabled={!canRevoke || revokeDeviceSession.isPending}
aria-label={`Revoke ${session.deviceName}`}
title="Revoke device session"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</ActionIcon>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{session.capabilities.map((capability) => (
<Badge
key={capability}
variant="light"
color="gray"
className="font-mono text-[11px]"
>
{capability}
</Badge>
))}
{session.scopes.map((scope) => (
<Badge
key={scope}
variant="outline"
color="gray"
className="font-mono text-[11px]"
>
{scope}
</Badge>
))}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</Section>
<Section title="API Access" icon={KeyRound}>
<div className="space-y-3 rounded-md border p-3">
<div className="grid gap-2 text-sm sm:grid-cols-3">

View file

@ -16,6 +16,7 @@ import {
import {
identityApi,
type CreateApiTokenInput,
type CreatePairingCodeInput,
type CreateInvitationInput,
type IdentityProfile,
type WorkspaceIdentity,
@ -287,6 +288,17 @@ export function useWorkspaceApiTokens(
});
}
export function useWorkspaceDeviceSessions(
workspaceId: string | null | undefined,
canManageDeviceSessions: boolean
) {
return useQuery({
queryKey: ['identity', 'workspaces', workspaceId, 'device-sessions'],
queryFn: () => identityApi.listDeviceSessions(requireWorkspaceId(workspaceId)),
enabled: !!workspaceId && canManageDeviceSessions,
});
}
export function useCreateWorkspaceInvitation(workspaceId: string | null | undefined) {
const queryClient = useQueryClient();
@ -301,6 +313,48 @@ export function useCreateWorkspaceInvitation(workspaceId: string | null | undefi
});
}
export function useCreateWorkspacePairingCode(workspaceId: string | null | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreatePairingCodeInput) =>
identityApi.createPairingCode(requireWorkspaceId(workspaceId), input),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['identity', 'workspaces', workspaceId, 'device-sessions'],
});
},
});
}
export function useRevokeWorkspaceDeviceSession(workspaceId: string | null | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) =>
identityApi.revokeDeviceSession(requireWorkspaceId(workspaceId), sessionId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['identity', 'workspaces', workspaceId, 'device-sessions'],
});
},
});
}
export function useTestWorkspaceDeviceSession(workspaceId: string | null | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) =>
identityApi.testDeviceSession(requireWorkspaceId(workspaceId), sessionId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['identity', 'workspaces', workspaceId, 'device-sessions'],
});
},
});
}
export function useCreateWorkspaceApiToken(workspaceId: string | null | undefined) {
const queryClient = useQueryClient();

View file

@ -109,6 +109,106 @@ export interface CreateApiTokenResult {
secret: string;
}
export interface DeviceSessionSummary {
id: string;
workspaceId: string;
userId: string;
deviceName: string;
deviceType: string;
deviceId: string;
clientId: string;
clientMode: string;
capabilities: string[];
scopes: ClientAuthPermission[];
role: WorkspaceRole;
tokenPrefix: string;
nonce: string;
signedAt: string;
createdAt: string;
expiresAt: string;
revokedAt: string | null;
revokedBy: string | null;
lastSeenAt: string | null;
lastSeenIp: string | null;
connectionState:
| 'pairing'
| 'connected'
| 'reconnecting'
| 'auth_failed'
| 'unreachable'
| 'revoked'
| 'expired';
stateReason: string | null;
lastAuthFailure: string | null;
degradedReason: string | null;
}
export interface CreatePairingCodeInput {
deviceName: string;
deviceType?: string;
deviceId?: string;
clientId?: string;
clientMode?: string;
capabilities?: string[];
scopes?: ClientAuthPermission[];
role?: WorkspaceRole;
expiresAt?: string | null;
sessionExpiresAt?: string | null;
}
export interface PairingPayload {
code: string;
workspaceId: string;
deviceId: string;
clientId: string;
clientMode: string;
capabilities: string[];
scopes: ClientAuthPermission[];
role: WorkspaceRole;
nonce: string;
signedAt: string;
signature: string;
expiresAt: string;
}
export interface PairingCodeSummary {
id: string;
workspaceId: string;
createdBy: string;
codePrefix: string;
deviceName: string;
deviceType: string;
deviceId: string;
clientId: string;
clientMode: string;
capabilities: string[];
scopes: ClientAuthPermission[];
role: WorkspaceRole;
nonce: string;
signedAt: string;
createdAt: string;
expiresAt: string;
sessionExpiresAt: string;
usedAt: string | null;
usedBy: string | null;
revokedAt: string | null;
attemptCount: number;
lastAttemptAt: string | null;
}
export interface CreatePairingCodeResult {
pairing: PairingCodeSummary;
code: string;
payload: PairingPayload;
link: string;
}
export interface DeviceSessionTestResult {
session: DeviceSessionSummary;
allowed: boolean;
reason: string;
}
export const identityApi = {
getAuthContext: () => apiFetch<ClientAuthContext>('/api/auth/context'),
@ -189,6 +289,41 @@ export const identityApi = {
}
),
listDeviceSessions: (workspaceId: string) =>
apiFetch<DeviceSessionSummary[]>(
`/api/identity/workspaces/${encodeURIComponent(workspaceId)}/device-sessions`
),
createPairingCode: (workspaceId: string, input: CreatePairingCodeInput) =>
apiFetch<CreatePairingCodeResult>(
`/api/identity/workspaces/${encodeURIComponent(workspaceId)}/device-pairing-codes`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}
),
testDeviceSession: (workspaceId: string, sessionId: string) =>
apiFetch<DeviceSessionTestResult>(
`/api/identity/workspaces/${encodeURIComponent(
workspaceId
)}/device-sessions/${encodeURIComponent(sessionId)}/test`,
{
method: 'POST',
}
),
revokeDeviceSession: (workspaceId: string, sessionId: string) =>
apiFetch<DeviceSessionSummary>(
`/api/identity/workspaces/${encodeURIComponent(
workspaceId
)}/device-sessions/${encodeURIComponent(sessionId)}/revoke`,
{
method: 'POST',
}
),
updateMemberRole: (workspaceId: string, userId: string, role: WorkspaceRole) =>
apiFetch<WorkspaceMembership>(
`/api/identity/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(