mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
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:
parent
847c9d1287
commit
58f39ea2b5
23 changed files with 3338 additions and 68 deletions
|
|
@ -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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
332
server/src/__tests__/device-session-service.test.ts
Normal file
332
server/src/__tests__/device-session-service.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
877
server/src/services/device-session-service.ts
Normal file
877
server/src/services/device-session-service.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
580
server/src/storage/sqlite/device-session-repository.ts
Normal file
580
server/src/storage/sqlite/device-session-repository.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ describe('desktop onboarding', () => {
|
|||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
serverToken: 'vk_pat_secret',
|
||||
pairingPayload: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue