Multi-Device Management

Overview

Wafra supports secure access across multiple devices through a sophisticated approval system that maintains security while providing user convenience. Users can register passkeys on multiple devices (phones, tablets, computers) with a cryptographic approval workflow.

Device Registration Flow

First Device (During Onboarding)

Automatic Approval:

  • First passkey is automatically approved during user onboarding
  • Becomes the primary authentication method
  • Enables wallet operations immediately
  • No approval workflow required

Implementation: apps/server/src/api/controllers/passkey.ts:106-137

// Check if this is the first passkey for the user
const existingPasskeys = await prisma.passkey.findMany({
  where: { userId: user.id },
});
 
const isFirstPasskey = existingPasskeys.length === 0;
 
const passkey = await prisma.passkey.create({
  data: {
    userId: user.id,
    credentialId: credential.rawId,
    publicKey: credential.response.publicKey!,
    counter,
    deviceType: deviceType || "unknown",
    deviceName: deviceName || "Unknown Device",
    approved: isFirstPasskey, // First passkey is automatically approved
  },
});

Additional Devices

Approval Required:

  • New devices require approval from existing approved device
  • 24-hour approval window with automatic expiration
  • Real-time notifications via Socket.IO
  • Cryptographic proof of ownership required

Implementation: apps/app/app/auth/device-setup.tsx

const handleAddPasskey = async () => {
  try {
    await registerPasskey({
      deviceName: deviceName.trim(),
      deviceType: Platform.OS,
    });
 
    Alert.alert(
      "Device Added Successfully",
      `Your device "${deviceName}" has been added but needs approval from another device.`,
      [
        {
          text: "Check Status",
          onPress: () => router.push("/auth/approval-status"),
        },
      ]
    );
  } catch (error: any) {
    setError(error.message || "Failed to set up device");
  }
};

Device Approval Workflow

Pending Request Notification

Header Badge: apps/app/components/dashboard/Header.tsx

The header displays a notification badge when there are pending device approval requests:

const { showApprovalModal, pendingRequestsCount } = useDeviceApproval();
 
{
  pendingRequestsCount > 0 && (
    <View className="absolute -top-1 -right-1 bg-red-500 rounded-full w-4 h-4">
      <Text className="text-white text-xs font-bold">
        {pendingRequestsCount}
      </Text>
    </View>
  );
}

Device Management Interface

Device List: apps/app/app/devices/index.tsx

Users can view all their registered devices and pending requests:

const { data: passkeys } = useQuery({
  queryKey: ["passkeys"],
  queryFn: () => exec("/passkeys"),
});
 
const { data: pendingRequests } = useQuery({
  queryKey: ["passkeys", "pending"],
  queryFn: () => exec("/passkeys/approval/pending"),
});

Approval Process

Approval Interface: apps/app/app/devices/[id].tsx

Users can approve or reject pending device requests:

const handleApprove = async () => {
  try {
    await exec("/passkeys/approval/approve", {
      method: "POST",
      body: JSON.stringify({
        requestId: request.id,
        approved: true,
      }),
    });
 
    Alert.alert(
      "Device Approved",
      "The device has been approved successfully."
    );
    router.back();
  } catch (error: any) {
    setError(error.message || "Failed to approve device");
  }
};
 
const handleReject = async () => {
  try {
    await exec("/passkeys/approval/approve", {
      method: "POST",
      body: JSON.stringify({
        requestId: request.id,
        approved: false,
      }),
    });
 
    Alert.alert("Device Rejected", "The device has been rejected.");
    router.back();
  } catch (error: any) {
    setError(error.message || "Failed to reject device");
  }
};

Security Controls

Device Removal Protection

Security Checks: apps/server/src/api/controllers/passkey.ts:200-250

Multiple security controls prevent unauthorized device removal:

// Session age validation (≤5 minutes)
const sessionAge = Date.now() - ctx.session.iat * 1000;
const maxSessionAge = 5 * 60 * 1000; // 5 minutes
 
if (sessionAge > maxSessionAge) {
  throw new AppError(400, "Session too old for device removal");
}
 
// Cannot remove current session's passkey
if (passkey.id === ctx.session.passkeyId) {
  throw new AppError(400, "Cannot remove current device");
}
 
// Cannot remove last approved device
const approvedPasskeys = await prisma.passkey.findMany({
  where: { userId: user.id, approved: true },
});
 
if (approvedPasskeys.length <= 1) {
  throw new AppError(400, "Cannot remove last approved device");
}

Approval Expiration

Time Limits: apps/server/src/api/controllers/passkey.ts:150-180

Device approval requests automatically expire:

// Create approval request with 24-hour expiration
await prisma.deviceApprovalRequest.create({
  data: {
    userId: user.id,
    requestingPasskeyId: passkey.id,
    deviceName: deviceName || "Unknown Device",
    deviceType: deviceType || "unknown",
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
  },
});

Real-Time Features

Socket.IO Integration

Event Handling: apps/server/src/services/socket/socket.service.ts

Real-time notifications for device management:

// Device approval request notification
io.to(`user:${userId}`).emit("deviceApprovalRequest", {
  type: "deviceApprovalRequest",
  data: {
    requestId: request.id,
    deviceName: request.deviceName,
    deviceType: request.deviceType,
    createdAt: request.createdAt,
  },
});
 
// Device approval/rejection notification
io.to(`user:${userId}`).emit("deviceApprovalResponse", {
  type: "deviceApprovalResponse",
  data: {
    requestId: request.id,
    approved: request.approved,
    deviceName: request.deviceName,
  },
});

React Query Integration

Automatic Invalidation: apps/app/hooks/useDeviceApproval.tsx

Queries are automatically invalidated when device status changes:

const queryClient = useQueryClient();
 
// Invalidate relevant queries when device status changes
useEffect(() => {
  if (deviceApprovalResponse) {
    queryClient.invalidateQueries(["passkeys"]);
    queryClient.invalidateQueries(["passkeys", "pending"]);
  }
}, [deviceApprovalResponse, queryClient]);

Database Schema

Passkey Model

Schema: apps/server/prisma/schema.prisma

model Passkey {
  id           String   @id @default(cuid())
  userId       String
  credentialId String   @unique
  publicKey    String
  counter      Int      @default(0)
  deviceType   String?
  deviceName   String?
  approved     Boolean  @default(false)
  createdAt    DateTime @default(now())
  lastUsedAt   DateTime @default(now())
 
  user                    User                     @relation(fields: [userId], references: [id], onDelete: Cascade)
  deviceApprovalRequests  DeviceApprovalRequest[]  @relation("RequestingPasskey")
}

Device Approval Request Model

model DeviceApprovalRequest {
  id                   String   @id @default(cuid())
  userId               String
  requestingPasskeyId  String
  deviceName           String
  deviceType           String
  approved             Boolean?
  expiresAt            DateTime
  createdAt            DateTime @default(now())
 
  user             User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  requestingPasskey Passkey @relation("RequestingPasskey", fields: [requestingPasskeyId], references: [id], onDelete: Cascade)
}

API Endpoints

Passkey Management

tRPC Procedures:

  • passkey.generateRegistrationChallenge: Start passkey registration
  • passkey.verifyRegistration: Complete passkey registration
  • passkey.generateAuthenticationChallenge: Start passkey authentication
  • passkey.verifyAuthentication: Complete passkey authentication
  • passkey.listPasskeys: Get user’s devices
  • passkey.getPendingApprovals: Get pending approval requests
  • passkey.approveRequest: Approve/reject device requests
  • passkey.removePasskey: Remove device from account

REST Endpoints (Legacy)

  • GET /passkeys - List user’s passkeys
  • POST /passkeys/register - Register new passkey
  • POST /passkeys/authenticate - Authenticate with passkey
  • POST /passkeys/remove - Secure device removal with authentication checks
  • GET /passkeys/approval/pending - Get pending device approval requests
  • POST /passkeys/approval/approve - Approve or reject device requests
  • GET /passkeys/approval/status - Check approval status for requesting device

User Experience

Device Setup Flow

  1. User initiates device setup from device management screen
  2. System generates registration challenge with device metadata
  3. User registers passkey using device biometrics
  4. System creates approval request (if not first device)
  5. User receives notification of pending approval
  6. Existing device approves/rejects the new device
  7. User receives confirmation of approval status

Device Management Flow

  1. User views device list with approval status
  2. User can approve/reject pending requests
  3. User can remove devices with security checks
  4. Real-time updates via Socket.IO notifications
  5. Automatic query invalidation via React Query

Security Features

Multi-Layer Protection

  1. Cryptographic Proof: Each device must prove ownership through WebAuthn
  2. Approval Workflow: New devices require explicit approval
  3. Session Validation: Recent session required for sensitive operations
  4. Expiration Handling: Automatic cleanup of expired requests
  5. Last Device Protection: Cannot remove the last approved device

Audit Trail

  • All device operations are logged with timestamps
  • Approval/rejection actions are tracked
  • Device metadata is stored for security analysis
  • Session information is maintained for compliance

This multi-device management system provides enterprise-grade security while maintaining the simplicity users expect from modern financial applications.