workos-directory-sync
npx skills add https://github.com/workos/skills --skill workos-directory-sync
Agent 安装分布
Skill 文档
WorkOS Directory Sync
Step 1: Fetch Documentation (BLOCKING)
STOP. Do not proceed until complete.
WebFetch these docs for source of truth on Directory Sync implementation:
- https://workos.com/docs/directory-sync/understanding-events
- https://workos.com/docs/directory-sync/quick-start
- https://workos.com/docs/directory-sync/index
- https://workos.com/docs/directory-sync/identity-provider-role-assignment
- https://workos.com/docs/directory-sync/handle-inactive-users
- https://workos.com/docs/directory-sync/example-apps
- https://workos.com/docs/directory-sync/attributes
If this skill conflicts with fetched docs, follow the docs.
Step 2: Pre-Flight Validation
Environment Variables
Check for required credentials:
# Verify API key format
grep "WORKOS_API_KEY=sk_" .env* || echo "FAIL: API key missing or invalid format"
# Verify client ID
grep "WORKOS_CLIENT_ID" .env* || echo "FAIL: Client ID missing"
Both must exist before proceeding.
SDK Installation
Detect package manager and verify WorkOS SDK is installed:
# Check SDK exists
ls node_modules/@workos-inc/node 2>/dev/null || npm ls @workos-inc/node || echo "FAIL: SDK not installed"
If SDK missing, install based on detected package manager (npm/yarn/pnpm).
Step 3: Webhook Infrastructure (MANDATORY)
CRITICAL: Directory Sync uses webhooks for event delivery. Polling is NOT supported.
Decision Tree: New vs Existing Webhook Handler
Do you have existing WorkOS webhook handling?
|
+-- YES --> Extend existing handler to include Directory Sync events
| (see Step 4 for event types)
|
+-- NO --> Create new webhook endpoint
(see Step 4 for implementation pattern)
Webhook URL Requirements
Your webhook endpoint MUST:
- Be publicly accessible (use ngrok/tunneling for local dev)
- Accept POST requests
- Return 200 status within 30 seconds
- Handle duplicate event deliveries (events may retry)
Verification:
# Check webhook route exists (adjust path to your framework)
grep -r "POST.*webhook" app/ routes/ pages/ src/ 2>/dev/null || echo "WARN: No webhook route found"
Step 4: Implement Webhook Handler
Event Verification Pattern
Always verify webhook signatures before processing:
// Generic pattern - check fetched docs for SDK-specific method
const WorkOS = require('@workos-inc/node').WorkOS;
const workos = new WorkOS(process.env.WORKOS_API_KEY);
// Webhook handler
async function handleWebhook(request) {
const payload = request.body;
const signature = request.headers['workos-signature'];
// Verify signature (method name from SDK docs)
const event = workos.webhooks.constructEvent({
payload,
signature,
secret: process.env.WORKOS_WEBHOOK_SECRET
});
// Process event
await processDirectorySyncEvent(event);
return { status: 200 };
}
Signature verification is REQUIRED â unauthenticated webhooks expose your app to injection attacks.
Directory Sync Event Types
Process these event types in your handler:
Directory lifecycle:
dsync.activated– Directory connection establisheddsync.deleted– Directory connection removed
User lifecycle:
dsync.user.created– New user provisioneddsync.user.updated– User attributes changeddsync.user.deleted– User hard-deleted (rare – see inactive user handling)
Group lifecycle:
dsync.group.created– New group createddsync.group.updated– Group attributes changeddsync.group.deleted– Group removeddsync.group.user_added– User assigned to groupdsync.group.user_removed– User removed from group
Event Processing Pattern
Event received
|
+-- Verify signature (REQUIRED)
|
+-- Check event type
| |
| +-- dsync.activated --> Save directory_id, associate with organization
| |
| +-- dsync.user.created --> Create user record, link to directory_id
| |
| +-- dsync.user.updated --> Update user attributes
| | Check state field for inactive status
| |
| +-- dsync.user.deleted --> Remove user OR mark deleted
| |
| +-- dsync.group.* --> Update group memberships
| |
| +-- dsync.deleted --> Remove directory association
| Delete all users/groups for that directory
|
+-- Return 200 (event processed) or 500 (retry)
Step 5: Database Schema Design
Minimum Required Tables
directories:
id(your primary key)workos_directory_id(from event.directory.id)organization_id(your organization identifier)state(active/deleting/invalid_credentials)directory_type(azure scim v2.0, okta scim v2.0, etc.)
directory_users:
id(your primary key)workos_user_id(from event.data.id)directory_id(foreign key to directories table)emailfirst_name,last_namestate(active/inactive/suspended)custom_attributes(JSONB/JSON column)raw_attributes(JSONB/JSON – full payload for debugging)
directory_groups:
id(your primary key)workos_group_id(from event.data.id)directory_id(foreign key)nameraw_attributes(JSONB/JSON)
directory_group_memberships:
user_id(foreign key to directory_users)group_id(foreign key to directory_groups)- Primary key: (user_id, group_id)
Indexing Requirements
Add indexes for webhook processing performance:
CREATE INDEX idx_users_workos_id ON directory_users(workos_user_id);
CREATE INDEX idx_groups_workos_id ON directory_groups(workos_group_id);
CREATE INDEX idx_directories_workos_id ON directories(workos_directory_id);
CREATE INDEX idx_users_directory ON directory_users(directory_id);
CREATE INDEX idx_groups_directory ON directory_groups(directory_id);
Step 6: Handle Inactive Users (CRITICAL)
Decision Tree: Inactive User Handling
dsync.user.updated received with state = "inactive"
|
+-- Environment created AFTER Oct 19, 2023?
| |
| +-- YES --> WorkOS auto-deletes inactive users
| | You'll receive dsync.user.deleted
| | Handle as hard delete
| |
| +-- NO --> WorkOS retains inactive users
| You receive dsync.user.updated
| Decision: soft delete or retain?
|
+-- Your app's inactive user policy?
|
+-- Retain --> Mark user.state = inactive
| Preserve data, block login
|
+-- Delete --> Remove user record
Unlink from groups
Source: https://workos.com/docs/directory-sync/handle-inactive-users
Most directory providers (Okta, Azure AD, Google) use soft deletion â they mark users inactive rather than hard-deleting. Handle this in dsync.user.updated:
async function handleUserUpdated(event) {
const user = event.data;
if (user.state === 'inactive') {
// YOUR POLICY HERE
// Option A: Soft delete (recommended)
await db.users.update(user.id, {
state: 'inactive',
deactivated_at: new Date()
});
// Option B: Hard delete
await db.users.delete(user.id);
} else {
// Normal attribute update
await db.users.update(user.id, user);
}
}
Step 7: Initial Sync Handling
When dsync.activated fires, WorkOS performs initial directory sync. You’ll receive:
dsync.activatedevent- Burst of
dsync.user.createdevents (ALL existing users) - Burst of
dsync.group.createdevents (ALL existing groups) - Burst of
dsync.group.user_addedevents (ALL memberships)
Performance pattern:
async function handleActivated(event) {
const directory = event.data;
// Save directory record
await db.directories.create({
workos_directory_id: directory.id,
organization_id: directory.organization_id,
state: directory.state,
directory_type: directory.type
});
// Flag for batch processing mode
await cache.set(`directory:${directory.id}:initial_sync`, true, 3600);
}
async function handleUserCreated(event) {
const inInitialSync = await cache.get(`directory:${event.directory.id}:initial_sync`);
if (inInitialSync) {
// Batch insert (100+ users)
await batchQueue.add(event);
} else {
// Single insert (normal operation)
await db.users.create(event.data);
}
}
Expect 100-10,000+ users during initial sync for enterprise customers.
Step 8: Custom Attributes Mapping
Check fetched docs for attribute mapping capabilities. Directory Sync supports:
- Standard attributes – email, first_name, last_name, state
- Auto-mapped attributes – WorkOS automatically maps common fields
- Custom-mapped attributes – Admin Portal UI for customer-defined mappings
Store custom attributes in JSONB column:
// Event payload structure
{
"data": {
"id": "directory_user_01E...",
"emails": [{"primary": true, "value": "user@example.com"}],
"first_name": "Jane",
"last_name": "Doe",
"custom_attributes": {
"department": "Engineering",
"employee_id": "12345",
"cost_center": "R&D" // Custom-mapped by customer
}
}
}
Never hardcode custom attribute names â they’re customer-specific.
Step 9: Role Assignment (Advanced)
Check https://workos.com/docs/directory-sync/identity-provider-role-assignment for provider-specific role mapping capabilities.
Some directory providers support assigning app-specific roles:
- Okta â Role assignment via App Integration
- Azure AD â App Roles manifest
- Google Workspace â (check docs for support)
If available, roles appear in custom_attributes:
{
"custom_attributes": {
"role": "admin", // or roles: ["admin", "billing"]
}
}
Map directory roles to your internal RBAC system during user sync.
Verification Checklist (ALL MUST PASS)
# 1. Environment variables configured
env | grep WORKOS_API_KEY | grep "sk_" || echo "FAIL: API key invalid"
env | grep WORKOS_WEBHOOK_SECRET || echo "FAIL: Webhook secret missing"
# 2. Webhook endpoint exists and is routable
curl -X POST http://localhost:3000/webhooks/workos \
-H "Content-Type: application/json" \
-d '{"test": true}' \
| grep -E "200|401|403" || echo "FAIL: Webhook route not responding"
# 3. Database tables exist (adjust to your schema tool)
psql -c "\dt" | grep directory_users || echo "FAIL: Schema not created"
psql -c "\dt" | grep directory_groups || echo "FAIL: Schema not created"
# 4. Signature verification implemented
grep -r "workos.webhooks.constructEvent\|verifyWebhook" . || echo "FAIL: No signature verification found"
# 5. Event type handling
grep -r "dsync.user.created" . || echo "WARN: User creation not handled"
grep -r "dsync.user.updated" . || echo "WARN: User updates not handled"
grep -r "dsync.activated" . || echo "WARN: Directory activation not handled"
# 6. Application builds
npm run build || yarn build || echo "FAIL: Build errors"
Test webhook delivery:
- Configure webhook URL in WorkOS Dashboard:
https://your-domain.com/webhooks/workos - Use Dashboard’s “Test Webhook” button
- Verify event logged in your application
- Check response is 200 OK
Error Recovery
“Webhook signature verification failed”
Root cause: Signature mismatch between WorkOS and your verification.
Fix:
- Verify
WORKOS_WEBHOOK_SECRETmatches Dashboard value - Check raw request body is passed to verification (not parsed JSON)
- Ensure no middleware modifies request body before verification
Verification:
// WRONG - body already parsed
app.use(express.json());
app.post('/webhook', (req) => workos.webhooks.verify(req.body, sig));
// CORRECT - verify raw body
app.post('/webhook',
express.raw({type: 'application/json'}),
(req) => workos.webhooks.verify(req.body, sig)
);
“Duplicate user creation errors”
Root cause: Webhook retry after network failure, constraint violation on unique workos_user_id.
Fix: Use upsert pattern instead of insert:
INSERT INTO directory_users (workos_user_id, email, ...)
VALUES ($1, $2, ...)
ON CONFLICT (workos_user_id)
DO UPDATE SET email = $2, ...
This makes webhook handler idempotent.
“dsync.deleted event but users not cleaned up”
Root cause: Missing cascade delete or explicit cleanup logic.
Fix: When processing dsync.deleted, delete all related records:
async function handleDirectoryDeleted(event) {
const directoryId = event.directory.id;
// Delete in order: memberships â users â groups â directory
await db.groupMemberships.deleteWhere({ directory_id: directoryId });
await db.users.deleteWhere({ directory_id: directoryId });
await db.groups.deleteWhere({ directory_id: directoryId });
await db.directories.deleteWhere({ workos_directory_id: directoryId });
}
Or use database foreign key cascades: ON DELETE CASCADE
“Initial sync causes webhook timeout”
Root cause: Processing 1000+ user creation events synchronously exceeds 30s timeout.
Fix: Use async job queue for initial sync:
async function handleUserCreated(event) {
// Queue job instead of processing inline
await jobQueue.publish('directory.user.sync', event);
return 200; // Acknowledge immediately
}
// Separate worker processes jobs
worker.on('directory.user.sync', async (event) => {
await db.users.create(event.data);
});
“State management conflicts”
Root cause: Events arrive out of order (created â deleted â updated).
Fix: Use event timestamps and compare before applying updates:
async function handleUserUpdated(event) {
const existing = await db.users.findByWorkOSId(event.data.id);
if (existing && existing.last_synced_at > event.created_at) {
// Stale event, discard
return;
}
await db.users.update(event.data.id, {
...event.data,
last_synced_at: event.created_at
});
}
“Custom attributes not syncing”
Root cause: Custom mapping not configured by customer in Admin Portal.
Fix: This is customer configuration, not code issue.
- Customer must configure custom attribute mappings in Admin Portal
- Check
event.data.custom_attributesis populated - If empty, customer hasn’t mapped attributes yet
- Provide Admin Portal URL to customer
“Groups created but memberships empty”
Root cause: Event processing order â dsync.group.created arrives before dsync.group.user_added.
Fix: This is expected behavior. Process group creation first, memberships arrive seconds later:
// Event sequence:
// 1. dsync.group.created â Create group record
// 2. dsync.group.user_added â Add user_id to memberships table
Not a bug â WorkOS sends events in phases during sync.
Related Skills
- workos-api-directory-sync: Direct API access patterns for directory queries
- workos-admin-portal: Customer self-service for directory connections
- workos-events: Generic webhook handling infrastructure
- workos-integrations: Provider-specific directory setup guides