Skip to main content

Overview

Joystick uses a multi-layered authentication system with support for JWT tokens, API keys, and internal network requests. Access control is enforced through user permissions and device-specific allow lists.

Authentication methods

The platform supports three authentication methods, processed in this order:
1

Internal network detection

Requests from internal IPs bypass token validation
2

API key validation

Static keys for service-to-service communication
3

JWT token validation

User authentication via PocketBase tokens

JWT authentication

The primary method for user authentication using PocketBase-issued tokens.
Standard OAuth 2.0 bearer token:
curl -X POST http://localhost:8000/api/run/device_id/action_name \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Content-Type: application/json" \
  -d '{"param": "value"}'

API key authentication

Static keys for service-to-service communication:
curl -X POST http://localhost:8000/api/run/device_id/action_name \
  -H "X-API-Key: dev-api-key-12345" \
  -H "Content-Type: application/json" \
  -d '{"param": "value"}'
environment:
  - JOYSTICK_API_KEY=dev-api-key-12345
API key requests default to the system user unless X-User header specifies a different user ID.

Internal network requests

Requests from internal IPs are automatically authenticated:
packages/core/src/auth.ts
const isInternalRequest = (
  headers: Record<string, string | undefined>
): boolean => {
  const forwardedFor = headers["x-forwarded-for"];
  const realIp = headers["x-real-ip"];
  const remoteAddr = headers["x-remote-addr"];

  const internalIps = [
    "127.0.0.1",
    "::1",
    "localhost",
    "172.16.0.0/12",
    "192.168.0.0/16",
    "10.0.0.0/8",
  ];

  const clientIp = forwardedFor || realIp || remoteAddr;
  if (clientIp && internalIps.some((ip) => clientIp.includes(ip))) {
    return true;
  }

  // Also check user agent for internal tools
  const userAgent = headers["user-agent"];
  if (
    userAgent &&
    (userAgent.includes("curl") ||
      userAgent.includes("node") ||
      userAgent.includes("bun"))
  ) {
    return true;
  }

  return false;
};
Internal requests are useful for Docker inter-service communication and local development.

Authentication context

Each request creates an authentication context:
packages/core/src/auth.ts
export interface AuthContext {
  user: any | null;           // PocketBase user record
  userId: string | null;      // User ID or system user
  isApiKey: boolean;          // True if API key auth
  isInternal: boolean;        // True if internal network
  isSuperuser: boolean;       // True if admin user
}
The context is available in all route handlers:
packages/joystick/src/index.ts
.post("/api/run/:device/:action", async ({ params, body, auth }) => {
  const userId = auth.userId || "system";
  const userName = auth.user?.name || auth.user?.email || "system";
  
  // Use auth context for authorization
});

Token validation

JWT tokens are validated by PocketBase:
packages/core/src/auth.ts
if (token) {
  try {
    const tempPb = new PocketBase(POCKETBASE_URL);
    tempPb.authStore.save(token, null);

    // Validate by refreshing
    const authData = await tempPb.collection("users").authRefresh();

    if (authData && authData.record) {
      authContext.user = authData.record;
      authContext.userId = authData.record.id;
    } else {
      // Try superuser collection
      const authData = await tempPb
        .collection("_superusers")
        .authRefresh();
      if (authData && authData.record) {
        authContext.isSuperuser = !!authData.record.isSuperuser;
        authContext.user = authData.record;
        authContext.userId = authData.record.id;
      }
    }
    return { auth: authContext };
  } catch (error) {
    console.error("PocketBase token validation failed:", error);
  }
}

return status(401, "Unauthorized");

Permission system

Joystick uses a feature-based permission system for fine-grained access control.

Permission structure

packages/core/src/types/db.types.ts
export type PermissionsRecord = {
  id: string;
  name: string;              // Permission identifier
  users: RecordIdString[];   // Users with this permission
  created?: IsoDateString;
  updated?: IsoDateString;
};

Built-in permissions

  • device-cpsi - Access cellular signal information
  • device-battery - View battery telemetry
  • device-gps - Read GPS coordinates
  • device-imu - Access IMU sensor data
  • notifications - Send platform notifications
  • admin - Full administrative access

Permission example

{
  "id": "perm_123",
  "name": "device-cpsi",
  "users": [
    "user_abc",
    "user_def",
    "user_ghi"
  ]
}

Device access control

Devices use an allow list to restrict control access:
packages/core/src/types/db.types.ts
export type DevicesRecord = {
  id: string;
  name?: string;
  allow?: RecordIdString[];  // Authorized user IDs
  // ...
};

Access enforcement

When executing actions, the platform verifies the user is in the device’s allow list:
packages/joystick/src/index.ts
const userId = auth.userId || "system";
const userPb = auth.isApiKey || auth.isInternal 
  ? pb 
  : await tryImpersonate(userId);

const result = await userPb
  .collection("devices")
  .getFullList<DeviceResponse>(1, {
    filter: `id = "${params.device}"`,
  });

if (result.length !== 1) {
  throw new Error(`Device ${params.device} not found`);
}
If a user is not in the allow list, PocketBase returns no results, preventing unauthorized access.

Impersonation

The platform impersonates users to enforce PocketBase collection rules:
const tryImpersonate = async (userId: string) => {
  const tempPb = new PocketBase(POCKETBASE_URL);
  // Set user context for collection rule evaluation
  tempPb.authStore.save(token, { id: userId });
  return tempPb;
};

Authorization flow

Complete authorization flow for device actions:

Swagger documentation

The API includes OpenAPI documentation with auth examples:
packages/joystick/src/index.ts
.use(
  swagger({
    documentation: {
      info: {
        title: "Joystick API",
        version: "0.0.0",
      },
      components: {
        securitySchemes: {
          bearerAuth: {
            type: "http",
            scheme: "bearer",
            bearerFormat: "JWT",
          },
          apiKey: {
            type: "apiKey",
            in: "header",
            name: "X-API-Key",
          },
        },
      },
      security: [{ bearerAuth: [] }, { apiKey: [] }],
    },
  })
)
Access interactive docs at:
http://localhost:8000/swagger

Error responses

Invalid or missing authentication:
{
  "success": false,
  "error": "Authentication required"
}
Common causes:
  • Missing Authorization header
  • Expired JWT token
  • Invalid API key

Security best practices

  • Store tokens securely (never in localStorage for sensitive apps)
  • Implement token refresh before expiration
  • Use short-lived tokens (15-60 minutes)
  • Revoke tokens on logout
  • Rotate API keys regularly
  • Use different keys per environment
  • Never commit keys to version control
  • Limit API key usage to internal services
  • Use HTTPS in production
  • Configure proper CORS policies
  • Implement rate limiting
  • Monitor authentication failures
  • Follow principle of least privilege
  • Regularly audit device allow lists
  • Remove access when users leave
  • Use permissions for feature gating

User context in actions

Actions receive user context for logging and authorization:
packages/joystick/src/index.ts
const userId = auth.userId || "system";
const userName = auth.user?.name || auth.user?.email || "system";

// Pass to action parser
const command = parseActionCommand(
  device,
  run.command,
  body as Record<string, unknown>,
  { userId }
);

// Log execution
enhancedLogger.info(
  {
    user: { name: userName, id: userId },
    device,
    action: params.action,
  },
  "Command executed successfully"
);
The $userId parameter is available in action commands:
{
  "command": "logger 'Command executed by $userId'"
}

PocketBase Auth

PocketBase authentication documentation

Devices

Configure device access control

Actions

Execute authenticated actions

Build docs developers (and LLMs) love