Clerk Authentication Integration¶
See also: Clerk Webhook Implementation Plan for webhook handler setup that synchronizes user data between Clerk and the platform.
This document describes the pure event-sourced User authentication system integrated with Clerk for the LBS Foundry platform.
Overview¶
The authentication system combines: - Clerk: External authentication provider (handles signup, login, password management) - Event Sourcing: User aggregate with complete event-driven lifecycle - Projections: User data built from events as read models - Marten Storage: Events as source of truth, projections for queries - Custom ACL: Role-based authorization independent of Clerk
Pure Event Sourcing Architecture¶
Events are the source of truth:
- User Commands:
CreateUser,UpdateUser,DeleteUser - User Events:
UserCreated,UserUpdated,UserDeleted - User Projection:
UserContractbuilt from events (read model) - Authentication: Queries projections, not events directly
Architecture¶
graph TD
A[Frontend with Clerk] -->|JWT Token| B[API Authentication Handler]
B --> C[Clerk JWT Validation]
C --> D[User Provisioning Service]
D --> E[User Projections Query]
F[Clerk Webhooks] --> G[Webhook Importer]
G --> H[User Commands]
H --> I[User Aggregate]
I --> J[User Events]
J --> K[Event Store]
K --> L[Projection Builder]
L --> M[UserContract Projections]
E --> M
subgraph "Event Sourcing"
H
I
J
K
end
subgraph "Read Side"
L
M
E
end
Data Flow¶
1. User Authentication¶
1. User signs up/logs in via Clerk (frontend)
2. Clerk issues JWT token
3. Frontend sends requests with Bearer token
4. API validates JWT with Clerk
5. System provisions internal User if needed
6. Request proceeds with user context
2. User Lifecycle Management (Event Sourcing)¶
1. User changes occur in Clerk (signup, profile update, deletion)
2. Clerk sends webhook to API
3. Webhook importer processes event
4. Generates User commands (Create/Update/Delete)
5. User aggregate handles commands and generates events
6. Events stored in Marten event store
7. Projection builder updates UserContract projections
8. Authentication queries updated projections
Implementation Guide¶
Step 1: Configure Marten¶
Add to your Marten configuration:
services.AddMarten(options =>
{
options.Connection(connectionString);
// Add user authentication support
options.ConfigureMartenForAuth();
// Your existing configuration...
});
Step 2: Add Clerk Authentication¶
In Program.cs:
// Add Clerk authentication
builder.Services.AddClerkAuthentication(builder.Configuration["Clerk:SecretKey"]);
// Enable authentication middleware
app.UseAuthentication();
app.UseAuthorization();
Step 3: Configuration¶
Add to appsettings.json:
Step 4: Register Services¶
// In your DI registration
// Register webhook importer
services.AddImporter<ClerkWebhookImporter, ClerkWebhookImporterContract>("ClerkWebhook");
// Register user management service
services.AddScoped<IUserManagementService, UserManagementService>();
Step 5: Add Webhook Endpoint¶
Create an endpoint to receive Clerk webhooks:
[AllowAnonymous]
public class ClerkWebhookEndpoint : Endpoint<ClerkWebhookImporterContract, string>
{
private readonly IImportExecutor _importExecutor;
public ClerkWebhookEndpoint(IImportExecutor importExecutor)
{
_importExecutor = importExecutor;
}
public override void Configure()
{
Post("/api/webhooks/clerk");
AllowAnonymous();
}
public override async Task HandleAsync(ClerkWebhookImporterContract req, CancellationToken ct)
{
var result = await _importExecutor.ExecuteAsync("ClerkWebhook", req);
if (result.Success)
{
await SendOkAsync("Webhook processed", ct);
}
else
{
await SendAsync("Webhook processing failed", 500, ct);
}
}
}
Step 6: Configure Clerk Webhooks¶
In your Clerk dashboard:
- Go to Webhooks section
- Add endpoint:
https://your-api.com/api/webhooks/clerk - Select events:
user.created,user.updated,user.deleted - Add webhook secret to your configuration
Creating Users¶
Method 1: Via Clerk Webhooks (Recommended)¶
Users are automatically created when they sign up through Clerk:
// No code needed - happens automatically via webhooks
// 1. User signs up in frontend via Clerk
// 2. Clerk sends user.created webhook
// 3. System creates User via CreateUserCommand
// 4. UserCreatedEvent generated and stored
// 5. UserContract projection created
Method 2: Programmatically via Commands¶
public class UserService
{
private readonly IUserManagementService _userService;
public async Task<UserId> CreateUserAsync(string email, string firstName, string lastName)
{
// Create user via command (generates events)
var userId = await _userService.CreateUserAsync(
email: email,
firstName: firstName,
lastName: lastName
);
return userId;
}
}
Method 3: Direct Command Execution¶
public class AdvancedUserService
{
private readonly ICommandExecutor _commandExecutor;
public async Task CreateUserWithCustomPropertiesAsync()
{
var userId = UserId.NewLuckBoxUserId(Guid.NewGuid().ToString());
var command = new CreateUserCommand
{
AggregateRootId = userId,
Email = "user@example.com",
FirstName = "John",
LastName = "Doe",
EmailVerified = true,
Status = UserStatus.Active,
Roles = ["Member", "Premium"]
};
// Execute command - generates UserCreatedEvent
await _commandExecutor.ExecuteAsync(command);
}
}
Clerk ID Mapping¶
How Users Link to Clerk¶
When a user is created via Clerk webhook:
// Clerk webhook provides external ID
var clerkUserId = "user_2ABC123DEF456"; // From Clerk
// Internal User ID generated from Clerk ID
var userId = UserId.NewLuckBoxUserId(clerkUserId);
// Command links them
var command = new CreateUserCommand
{
AggregateRootId = userId, // Internal ID
ClerkUserId = clerkUserId, // External reference
Email = "user@example.com"
// ... other properties
};
Querying by Clerk ID¶
// Find user by Clerk ID
var user = await _userService.GetUserByClerkIdAsync("user_2ABC123DEF456");
// Or via session query
var user = await _session.Query<UserContract>()
.Where(u => u.ClerkUserId == "user_2ABC123DEF456")
.FirstOrDefaultAsync();
ID Relationship¶
Clerk User ID: "user_2ABC123DEF456"
↓
Internal User ID: Generated GUID based on Clerk ID
↓
UserContract.ClerkUserId: "user_2ABC123DEF456" (reference back)
Usage Examples¶
Protecting Endpoints¶
FastEndpoints:
public class ProtectedEndpoint : Endpoint<MyRequest, MyResponse>
{
public override void Configure()
{
Get("/api/protected");
Policies(AuthorizationPolicies.RequireMember);
}
public override async Task HandleAsync(MyRequest req, CancellationToken ct)
{
// Access user information
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var clerkId = User.FindFirst("clerk_user_id")?.Value;
var email = User.FindFirst(ClaimTypes.Email)?.Value;
var isInRole = User.IsInRole("Member");
// Your endpoint logic...
}
}
Standard Controllers:
[Authorize(Policy = AuthorizationPolicies.RequireMember)]
[ApiController]
public class UserController : ControllerBase
{
[HttpGet("profile")]
public IActionResult GetProfile()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var email = User.FindFirst(ClaimTypes.Email)?.Value;
return Ok(new { userId, email });
}
}
Querying Users¶
// Direct Marten queries
public class UserService
{
private readonly IDocumentSession _session;
public async Task<List<MartenUser>> GetActiveUsersAsync()
{
return await _session.Query<MartenUser>()
.Where(u => u.Status == UserStatus.Active)
.ToListAsync();
}
public async Task<MartenUser?> GetByClerkIdAsync(string clerkId)
{
return await _session.Query<MartenUser>()
.FirstOrDefaultAsync(u => u.ClerkUserId == clerkId);
}
}
Managing User Commands¶
// Execute user commands directly
public class UserManagementService
{
private readonly ICommandExecutor _commandExecutor;
public async Task UpdateUserRoleAsync(UserId userId, List<string> roles)
{
var command = new UpdateUserCommand
{
AggregateRootId = userId,
Roles = roles
};
await _commandExecutor.ExecuteAsync(command);
}
}
Data Models¶
User Events (Source of Truth)¶
UserCreatedEvent:
{
"aggregateRootId": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"clerkUserId": "user_2ABC123DEF456",
"emailVerified": true,
"createdAt": "2025-01-02T10:30:00Z",
"status": "Active",
"roles": ["Member"],
"timestamp": "2025-01-02T10:30:00Z"
}
UserUpdatedEvent:
{
"aggregateRootId": "550e8400-e29b-41d4-a716-446655440000",
"email": "newemail@example.com",
"roles": ["Member", "Premium"],
"lastSignInAt": "2025-01-02T15:45:00Z",
"timestamp": "2025-01-02T15:45:00Z"
}
UserContract (Projection - Read Model)¶
Built from events via projection builder:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userName": "user@example.com",
"normalizedUserName": "USER@EXAMPLE.COM",
"email": "user@example.com",
"normalizedEmail": "USER@EXAMPLE.COM",
"emailConfirmed": true,
"firstName": "John",
"lastName": "Doe",
"fullName": "John Doe",
"clerkUserId": "user_2ABC123DEF456",
"createdAt": "2025-01-02T10:30:00Z",
"lastLoginAt": "2025-01-02T15:45:00Z",
"status": "Active",
"roles": ["Member"],
"version": 3,
"lastUpdated": "2025-01-02T15:45:00Z",
"securityStamp": "...",
"concurrencyStamp": "..."
}
Event Store Structure¶
Stream: user-550e8400-e29b-41d4-a716-446655440000
├── UserCreatedEvent (version 1)
├── UserUpdatedEvent (version 2)
└── UserUpdatedEvent (version 3)
↓
Projection Builder
↓
UserContract Document
Role Management¶
Default Behavior¶
- New users automatically get "Member" role
- Roles stored as simple string array
- Custom authorization policies define permissions
Adding Custom Roles¶
// Update authorization policies
public static class AuthorizationPolicies
{
public const string RequireMember = "RequireMember";
public const string RequireAdmin = "RequireAdmin";
public static void AddCustomPolicies(this AuthorizationOptions options)
{
options.AddPolicy(RequireMember, policy =>
policy.RequireRole("Member"));
options.AddPolicy(RequireAdmin, policy =>
policy.RequireRole("Admin"));
}
}
Event Sourcing Benefits¶
Full Audit Trail¶
- Every user change is captured as an event
- Complete history of user lifecycle
- Immutable event log for compliance and debugging
- Time travel: Reconstruct user state at any point in time
- Event replay: Rebuild projections from events
Event Types¶
UserCreatedEvent: User registration/signupUserUpdatedEvent: Profile changes, role assignments, login trackingUserDeletedEvent: Account deletion/deactivation
Command → Event → Projection Flow¶
// 1. Execute command
await _commandExecutor.ExecuteAsync(new UpdateUserCommand
{
AggregateRootId = userId,
Roles = ["Member", "Admin"]
});
// 2. Event automatically generated and stored
// UserUpdatedEvent created with roles change
// 3. Projection automatically updated
// UserContract.Roles = ["Member", "Admin"]
// 4. Authentication uses updated projection
var user = await _session.LoadAsync<UserContract>(userId);
var isAdmin = user.Roles.Contains("Admin"); // true
Advanced Event Sourcing Operations¶
// Get complete user event history
var events = await _session.Events.FetchStreamAsync(userId.Value);
// Reconstruct user state at specific time
var userAggregate = await _session.Events.AggregateStreamAsync<UserAggregate>(
userId.Value, timestamp: DateTime.UtcNow.AddDays(-30));
// Rebuild projection from events
await _session.Events.RebuildProjectionAsync<UserContract>(userId.Value);
// Get all events of specific type
var userCreatedEvents = await _session.Events
.QueryAllRawEvents()
.Where(e => e.EventTypeName == "UserCreated")
.ToListAsync();
Projection Rebuilding¶
If you need to change the projection logic:
// Rebuild all user projections from events
await _session.Events.RebuildProjectionAsync<UserContractBuilder>();
// Or rebuild specific user
await _session.Events.RebuildProjectionAsync<UserContract>(userId.Value);
Security Considerations¶
JWT Token Validation¶
- Tokens validated against Clerk's public keys
- Automatic token expiration handling
- Clerk user ID mapped to internal user ID
Authorization¶
- Authentication handled by Clerk
- Authorization managed internally via roles
- Custom policies for fine-grained permissions
Webhook Security¶
- Verify webhook signatures from Clerk
- Use HTTPS for webhook endpoints
- Implement idempotency for webhook processing
Troubleshooting¶
Common Issues¶
Authentication Fails: - Check Clerk secret key configuration - Verify token format (Bearer prefix) - Check Clerk dashboard for API key permissions
User Not Found: - User may not be provisioned yet - Check webhook delivery in Clerk dashboard - Verify webhook endpoint is accessible
Role Issues: - Check user role assignment in database - Verify authorization policy configuration - Ensure role names match exactly
Debugging¶
Enable detailed logging:
services.AddLogging(builder =>
{
builder.AddFilter("LBS.Domain.Infrastructure.Authentication", LogLevel.Debug);
builder.AddFilter("LBS.Domain.Infrastructure.Import", LogLevel.Debug);
});
Migration from Existing Auth¶
If migrating from Entity Framework Identity:
- Data Migration: Export existing users and roles
- Event Creation: Generate
UserCreatedEventfor each user - Role Mapping: Map EF roles to new role system
- Cleanup: Remove EF Identity dependencies
- Testing: Verify authentication flow end-to-end
This authentication system provides a robust, scalable foundation for user management with full event sourcing capabilities and seamless Clerk integration.