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:
Internal network detection
Requests from internal IPs bypass token validation
API key validation
Static keys for service-to-service communication
JWT token validation
User authentication via PocketBase tokens
JWT authentication
The primary method for user authentication using PocketBase-issued tokens.
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"}'
Default key (docker-compose.yml)
Custom key
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
User lacks required permissions: {
"success" : false ,
"error" : "Missing required permissions: device-cpsi"
}
Solution: Add user to the permission’s users array User not in device allow list: {
"success" : false ,
"error" : "Access denied: You don't have permission to control this device"
}
Solution: Add user ID to device’s allow array
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