@open-core/identity 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,681 +1,77 @@
1
1
  # @open-core/identity
2
2
 
3
- Flexible identity and permission system for OpenCore. Provides multiple authentication strategies, role management, and permission-based authorization through the framework's Principal system.
3
+ Enterprise-grade identity, authentication, and authorization plugin for the OpenCore Framework.
4
4
 
5
- ## What It Solves
5
+ ## Documentation Index
6
6
 
7
- - **Flexible Authentication**: Choose between local (auto-create), credentials (username/password), or external API authentication
8
- - **Account Management**: Create and lookup accounts via multiple identifiers (license/discord/steam)
9
- - **Role-Based Permissions**: Assign roles with hierarchical ranks and base permissions
10
- - **Custom Permissions**: Per-account permission overrides (additions or negations)
11
- - **Authorization**: Expose `Principal` with combined permissions for `@Guard` decorators
12
- - **Bans**: Temporary or permanent account bans with expiration tracking
13
- - **Flexible Storage**: Use local database OR external API with RAM caching
7
+ - [Architecture & Dependency Injection](./docs/architecture.md) - Learn about constructor injection and DI.
8
+ - [Authentication Modes](./docs/auth-modes.md) - Details on `local`, `credentials`, and `api` auth.
9
+ - [Principal Modes](./docs/principal-modes.md) - Details on `roles`, `db`, and `api` authorization.
10
+ - [Implementing Contracts](./docs/contracts.md) - How to build your own `IdentityStore` or `RoleStore`.
14
11
 
15
- ## Installation
12
+ ## Features
16
13
 
17
- ```bash
18
- pnpm add @open-core/identity
19
- ```
20
-
21
- ## Authentication Strategies
22
-
23
- ### 1. Local Authentication (Default)
24
-
25
- Auto-creates accounts based on FiveM identifiers. Best for traditional FiveM servers.
26
-
27
- **Features:**
28
- - Auto-creates accounts on first connection
29
- - Uses FiveM identifiers (license/discord/steam)
30
- - Stores everything in local database
31
- - Generates UUID-based linkedId
32
-
33
- **Setup:**
34
-
35
- ```ts
36
- import { container } from "tsyringe";
37
- import { Identity } from "@open-core/identity";
38
-
39
- Identity.setup(container, {
40
- authProvider: "local",
41
- principalProvider: "local",
42
- useDatabase: true, // default
43
- });
44
- ```
45
-
46
- ### 2. Credentials Authentication
47
-
48
- Username/password authentication against local database.
49
-
50
- **Features:**
51
- - Validates username + password (bcrypt)
52
- - Requires explicit registration (no auto-create)
53
- - Stores accounts in local database
54
- - Requires `password_hash` column (migration 005)
55
-
56
- **Setup:**
57
-
58
- ```ts
59
- Identity.setup(container, {
60
- authProvider: "credentials",
61
- principalProvider: "local",
62
- useDatabase: true,
63
- });
64
- ```
65
-
66
- **Note:** Requires implementing `findByUsername` in AccountRepository for production use.
67
-
68
- ### 3. API Authentication
69
-
70
- Delegates authentication to external API. No local database required.
71
-
72
- **Features:**
73
- - POSTs credentials to external API
74
- - Receives linkedId from API
75
- - Caches auth results in RAM (configurable TTL)
76
- - Optional: sync to local DB for offline fallback
77
-
78
- **Setup:**
79
-
80
- ```ts
81
- Identity.setup(container, {
82
- authProvider: "api",
83
- principalProvider: "api",
84
- useDatabase: false, // no DB needed
85
- });
86
- ```
87
-
88
- **Convars:**
89
-
90
- ```
91
- set identity_api_auth_url "https://your-api.com/auth"
92
- set identity_api_principal_url "https://your-api.com/principals"
93
- set identity_api_headers '{"Authorization": "Bearer YOUR_TOKEN"}'
94
- set identity_api_timeout 5000
95
- set identity_cache_ttl 300000
96
- ```
97
-
98
- ## API Contracts
99
-
100
- ### POST /auth
101
-
102
- **Request:**
103
-
104
- ```json
105
- {
106
- "credentials": {
107
- "username": "player123",
108
- "password": "secret"
109
- },
110
- "identifiers": {
111
- "license": "license:abc123",
112
- "discord": "123456789",
113
- "steam": "steam:110000123456789"
114
- }
115
- }
116
- ```
117
-
118
- **Response:**
119
-
120
- ```json
121
- {
122
- "success": true,
123
- "linkedId": "user_abc123",
124
- "isNewAccount": false
125
- }
126
- ```
127
-
128
- ### GET /principals/:linkedId
129
-
130
- **Response:**
131
-
132
- ```json
133
- {
134
- "name": "Administrator",
135
- "rank": 100,
136
- "permissions": ["admin.*", "player.kick", "player.ban"],
137
- "meta": {
138
- "roleId": 1,
139
- "roleName": "admin"
140
- }
141
- }
142
- ```
143
-
144
- ## Data Model
145
-
146
- ### Entities
147
-
148
- - **Account**: User identity with identifiers, role assignment, custom permissions, and ban status
149
- - **Role**: Named role (e.g., "admin", "moderator") with display name, rank weight, and base permissions
150
-
151
- ### linkedId vs UUID
152
-
153
- The `linkedId` field replaces the old `uuid` field:
154
-
155
- - **Local accounts**: Auto-generated UUID (e.g., `"550e8400-e29b-41d4-a716-446655440000"`)
156
- - **API accounts**: ID from external system (e.g., `"user_123"`, `"discord:456789"`)
157
- - **Nullable**: Can be `null` if not needed
158
- - **Used in**: `player.accountID`, `Principal.id`
159
-
160
- ### Relationships
161
-
162
- - An Account has **one** Role (FK `role_id`, nullable)
163
- - Role provides **base permissions** and **rank**
164
- - Account can have **custom permissions** (additions with `+perm` or negations with `-perm`)
165
- - **Effective permissions** = Role permissions + Custom permissions (with negations applied)
166
-
167
- ## Migrations
168
-
169
- Run SQL migrations in order:
170
-
171
- 1. `migrations/001_accounts_table.sql` - Create accounts table
172
- 2. `migrations/002_roles_table.sql` - Create roles table and insert default "user" role
173
- 3. `migrations/003_alter_accounts_add_role.sql` - Add `role_id` and `custom_permissions` to accounts
174
- 4. `migrations/004_rename_uuid_to_linked_id.sql` - Rename `uuid` to `linked_id`, add `external_source`
175
- 5. `migrations/005_add_password_hash.sql` - Add `password_hash` (optional, only for credentials auth)
176
-
177
- **Important**: Execute migrations in order. Migrations 4 and 5 are for upgrading existing installations.
178
-
179
- ## Convars
180
-
181
- ### General
182
-
183
- - `identity_primary_identifier` (default: `"license"`): Priority identifier for account lookup/creation
184
- - `identity_auto_create` (default: `true`): Auto-create account on authentication if not found (local auth only)
185
- - `identity_default_role` (default: `"user"`): Name of the default role assigned to new accounts
186
-
187
- ### API Authentication
188
-
189
- - `identity_api_auth_url`: URL for authentication API endpoint
190
- - `identity_api_principal_url`: URL for principal/permissions API endpoint
191
- - `identity_api_headers`: JSON string of custom headers (e.g., `'{"Authorization": "Bearer TOKEN"}'`)
192
- - `identity_api_timeout` (default: `5000`): Request timeout in milliseconds
193
- - `identity_cache_ttl` (default: `300000`): Cache TTL in milliseconds (5 minutes)
194
- - `identity_api_allow_fallback` (default: `false`): Allow empty permissions if API fails
195
-
196
- ### Credentials Authentication
197
-
198
- - `identity_merge_identifiers` (default: `false`): Merge FiveM identifiers after credentials auth
199
-
200
- ## Quick Start
201
-
202
- ### Example: Local Auth (Auto-Create)
203
-
204
- ```ts
205
- // server/bootstrap.ts
206
- import { container } from "tsyringe";
207
- import { Identity } from "@open-core/identity";
208
-
209
- // Setup Identity with local auth
210
- Identity.setup(container, {
211
- authProvider: "local",
212
- principalProvider: "local",
213
- useDatabase: true,
214
- });
215
- ```
216
-
217
- ```ts
218
- // server/controllers/auth.controller.ts
219
- import { Server, Utils } from "@open-core/framework";
220
- import { Identity } from "@open-core/identity";
221
-
222
- @Server.Controller()
223
- export class AuthController {
224
- constructor(
225
- private readonly auth: Identity.LocalAuthProvider,
226
- private readonly players: Server.PlayerService,
227
- ) {}
228
-
229
- @Server.OnCoreEvent("core:playerSessionCreated")
230
- async handlePlayerSession({
231
- clientId,
232
- }: Server.PlayerSessionCreatedPayload): Promise<void> {
233
- const player = this.players.getByClient(clientId);
234
- if (!player) {
235
- throw new Utils.AppError(
236
- "PLAYER_NOT_FOUND",
237
- `Player ${clientId} not found during session creation`,
238
- "server",
239
- );
240
- }
241
-
242
- // Authenticate automatically using FiveM identifiers
243
- const result = await this.auth.authenticate(player, {});
244
-
245
- if (!result.success) {
246
- player.kick(result.error ?? "Authentication failed");
247
- return;
248
- }
249
-
250
- // Player is now authenticated and linked
251
- player.emit("auth:success", {
252
- linkedId: result.accountID,
253
- isNewAccount: result.isNewAccount,
254
- });
255
- }
256
- }
257
- ```
258
-
259
- ### Example: Credentials Auth (Username/Password)
260
-
261
- ```ts
262
- // server/bootstrap.ts
263
- import { container } from "tsyringe";
264
- import { Identity } from "@open-core/identity";
265
-
266
- Identity.setup(container, {
267
- authProvider: "credentials",
268
- principalProvider: "local",
269
- useDatabase: true,
270
- });
271
- ```
272
-
273
- ```ts
274
- // server/controllers/auth.controller.ts
275
- import { Server, Utils } from "@open-core/framework";
276
- import { Identity } from "@open-core/identity";
277
-
278
- @Server.Controller()
279
- export class AuthController {
280
- constructor(
281
- private readonly auth: Identity.CredentialsAuthProvider,
282
- private readonly players: Server.PlayerService,
283
- ) {}
284
-
285
- @Server.OnCoreEvent("core:playerSessionCreated")
286
- async handlePlayerSession({
287
- clientId,
288
- }: Server.PlayerSessionCreatedPayload): Promise<void> {
289
- const player = this.players.getByClient(clientId);
290
- if (!player) return;
291
-
292
- // Show login UI to player
293
- player.emit("auth:showLogin");
294
- }
295
-
296
- @Server.OnNet("auth:loginAttempt")
297
- async handleLoginAttempt(
298
- player: Server.Player,
299
- username: string,
300
- password: string,
301
- ): Promise<void> {
302
- const result = await this.auth.authenticate(player, {
303
- username,
304
- password,
305
- });
306
-
307
- if (!result.success) {
308
- player.emit("auth:loginFailed", result.error);
309
- return;
310
- }
311
-
312
- // Authentication successful
313
- player.emit("auth:loginSuccess", {
314
- linkedId: result.accountID,
315
- });
316
-
317
- // Spawn player
318
- player.emit("player:spawn", {
319
- position: { x: -1032.0, y: -2732.0, z: 13.8 },
320
- model: "mp_m_freemode_01",
321
- });
322
- }
323
-
324
- @Server.OnNet("auth:register")
325
- async handleRegister(
326
- player: Server.Player,
327
- username: string,
328
- password: string,
329
- ): Promise<void> {
330
- const result = await this.auth.register(player, {
331
- username,
332
- password,
333
- });
334
-
335
- if (!result.success) {
336
- player.emit("auth:registerFailed", result.error);
337
- return;
338
- }
339
-
340
- player.emit("auth:registerSuccess", {
341
- linkedId: result.accountID,
342
- });
343
- }
344
- }
345
- ```
346
-
347
- ### Example: API Auth (External System)
14
+ - **Multi-Strategy Authentication**: Support for `local`, `credentials`, and `api` strategies.
15
+ - **Hierarchical RBAC**: Rank-based authorization and permission merging.
16
+ - **Constructor Injection**: Services are automatically available in your classes via DI.
17
+ - **Stateless Architecture**: Decoupled persistence via implementable contracts.
348
18
 
349
- ```ts
350
- // server/bootstrap.ts
351
- import { container } from "tsyringe";
352
- import { Identity } from "@open-core/identity";
353
-
354
- Identity.setup(container, {
355
- authProvider: "api",
356
- principalProvider: "api",
357
- useDatabase: false, // No local DB needed
358
- });
359
- ```
360
-
361
- ```ts
362
- // server/controllers/auth.controller.ts
363
- import { Server, Utils } from "@open-core/framework";
364
- import { Identity } from "@open-core/identity";
365
-
366
- @Server.Controller()
367
- export class AuthController {
368
- constructor(
369
- private readonly auth: Identity.ApiAuthProvider,
370
- private readonly players: Server.PlayerService,
371
- ) {}
372
-
373
- @Server.OnCoreEvent("core:playerSessionCreated")
374
- async handlePlayerSession({
375
- clientId,
376
- }: Server.PlayerSessionCreatedPayload): Promise<void> {
377
- const player = this.players.getByClient(clientId);
378
- if (!player) return;
379
-
380
- // Show login UI - API will validate
381
- player.emit("auth:showLogin");
382
- }
383
-
384
- @Server.OnNet("auth:loginAttempt")
385
- async handleLoginAttempt(
386
- player: Server.Player,
387
- token: string,
388
- ): Promise<void> {
389
- // API provider will POST to external API
390
- const result = await this.auth.authenticate(player, { token });
391
-
392
- if (!result.success) {
393
- player.emit("auth:loginFailed", result.error);
394
- return;
395
- }
396
-
397
- // linkedId comes from external API
398
- player.emit("auth:loginSuccess", {
399
- linkedId: result.accountID,
400
- });
19
+ ## Quick Start (Constructor Injection)
401
20
 
402
- player.emit("player:spawn", {
403
- position: { x: -1032.0, y: -2732.0, z: 13.8 },
404
- model: "mp_m_freemode_01",
405
- });
406
- }
407
- }
408
- ```
409
-
410
- ### Example: Protect Handlers with Permissions
411
-
412
- The framework **secures all handlers by default**. Use `@Guard` for permission/rank checks:
21
+ The recommended way to use the identity system is through **Constructor Injection**. The framework handles the lifecycle for you.
413
22
 
414
23
  ```ts
415
24
  import { Server } from "@open-core/framework";
416
- import { Identity } from "@open-core/identity";
25
+ import { AccountService } from "@open-core/identity";
26
+ import { injectable } from "tsyringe";
417
27
 
28
+ @injectable()
418
29
  @Server.Controller()
419
- export class AdminController {
420
- constructor(private readonly accounts: Identity.AccountService) {}
421
-
422
- @Server.OnNet("admin:banPlayer")
423
- @Server.Guard({ permission: "admin.ban" })
424
- async banPlayer(player: Server.Player, targetId: number): Promise<void> {
425
- // player.accountID is guaranteed to exist (authenticated)
426
- const target = await this.accounts.findById(targetId);
427
- if (!target) {
428
- player.emit("error", "Target not found");
429
- return;
430
- }
431
-
432
- await this.accounts.ban(targetId, {
433
- reason: "Banned by admin",
434
- durationMs: 86400000, // 24 hours
435
- });
436
-
437
- player.emit("success", `Player ${targetId} banned successfully`);
438
- }
439
-
440
- @Server.OnNet("admin:givePermission")
441
- @Server.Guard({ rank: 100 }) // Only rank 100+ (super admin)
442
- async givePermission(
443
- player: Server.Player,
444
- targetId: number,
445
- permission: string,
446
- ): Promise<void> {
447
- await this.accounts.addCustomPermission(targetId, permission);
448
- player.emit("success", `Permission ${permission} granted`);
449
- }
450
-
451
- @Server.OnNet("chat:moderate")
452
- @Server.Guard({ rank: 50 }) // Moderator rank or higher
453
- async moderateChat(player: Server.Player, message: string): Promise<void> {
454
- // Moderation logic here
455
- player.emit("chat:moderated", message);
456
- }
457
- }
458
- ```
459
-
460
- **Note**: Use `@Server.Public()` to allow unauthenticated access to specific handlers.
461
-
462
- ## Services
463
-
464
- ### RoleService
465
-
466
- Manage roles and their permissions:
467
-
468
- ```ts
469
- const roleService = container.resolve(Identity.RoleService);
470
-
471
- // Create a role
472
- await roleService.create({
473
- name: "moderator",
474
- displayName: "Moderator",
475
- rank: 50,
476
- permissions: ["chat.moderate", "player.kick"],
477
- });
478
-
479
- // Add permission to role
480
- await roleService.addPermission(roleId, "player.mute");
481
-
482
- // Get default role
483
- const defaultRole = await roleService.getDefaultRole();
484
- ```
485
-
486
- ### AccountService
487
-
488
- Manage accounts and custom permissions:
489
-
490
- ```ts
491
- const accountService = container.resolve(Identity.AccountService);
492
-
493
- // Find account by linkedId
494
- const account = await accountService.findByLinkedId(linkedId);
495
-
496
- // Or find by ID
497
- const accountById = await accountService.findById(accountId);
498
-
499
- // Assign role
500
- await accountService.assignRole(accountId, roleId);
501
-
502
- // Add custom permission (override)
503
- await accountService.addCustomPermission(accountId, "special.feature");
504
-
505
- // Negate a role permission
506
- await accountService.addCustomPermission(accountId, "-chat.moderate");
507
-
508
- // Get effective permissions (role + custom)
509
- const permissions = await accountService.getEffectivePermissions(accountId);
510
-
511
- // Ban account
512
- await accountService.ban(accountId, {
513
- reason: "Violation of rules",
514
- durationMs: 86400000, // 24 hours
515
- });
516
- ```
517
-
518
- ### MemoryCacheService
519
-
520
- Cache service used by API providers:
30
+ export class MyController {
31
+ // AccountService is automatically injected
32
+ constructor(private readonly accounts: AccountService) {}
521
33
 
522
- ```ts
523
- const cache = container.resolve(Identity.MemoryCacheService);
524
-
525
- // Set with TTL
526
- cache.set("key", { data: "value" }, 60000); // 1 minute
527
-
528
- // Get
529
- const value = cache.get<{ data: string }>("key");
530
-
531
- // Delete
532
- cache.delete("key");
533
-
534
- // Clear all
535
- cache.clear();
536
- ```
537
-
538
- ## Permission System
539
-
540
- ### How Effective Permissions Work
541
-
542
- 1. **Base**: Start with role's base permissions
543
- 2. **Additions**: Custom permissions without `-` prefix are added
544
- 3. **Negations**: Custom permissions with `-` prefix remove base permissions
545
-
546
- Example:
547
-
548
- - Role "moderator" has: `["chat.moderate", "player.kick"]`
549
- - Account custom permissions: `["admin.view", "-player.kick"]`
550
- - **Effective**: `["chat.moderate", "admin.view"]` (kick negated)
551
-
552
- ### Principal Structure
553
-
554
- When a player is authenticated, the Principal Provider returns a `Principal`:
555
-
556
- ```ts
557
- {
558
- id: account.linkedId ?? String(account.id), // linkedId or numeric ID
559
- name: role.displayName, // e.g., "Administrator"
560
- rank: role.rank, // e.g., 100
561
- permissions: effectivePerms, // combined array
562
- meta: {
563
- accountId: account.id,
564
- roleId: role.id,
565
- roleName: role.name
566
- }
567
- }
568
- ```
569
-
570
- ## Error Handling
571
-
572
- The module throws `AppError` for security violations:
573
-
574
- - **No linked account**: `AppError('UNAUTHORIZED', 'Player is not authenticated')`
575
- - **Account not found**: `AppError('UNAUTHORIZED', 'Linked account not found')`
576
- - **Account banned**: `AppError('PERMISSION_DENIED', 'Account is banned', { banReason, banExpires })`
577
-
578
- Handle these in your error handlers or let the framework's default handler catch them.
579
-
580
- ## Migration Guide
581
-
582
- ### From uuid to linkedId
583
-
584
- If you're upgrading from an earlier version:
585
-
586
- 1. Run migration 004: `migrations/004_rename_uuid_to_linked_id.sql`
587
- 2. Update code: `findByUuid()` → `findByLinkedId()`
588
- 3. Update code: `account.uuid` → `account.linkedId`
589
- 4. Everything else works the same
590
-
591
- ### Adding API Authentication
592
-
593
- 1. Set up your external API with the contracts above
594
- 2. Configure convars
595
- 3. Update setup:
596
-
597
- ```ts
598
- Identity.setup(container, {
599
- authProvider: "api",
600
- principalProvider: "api",
601
- useDatabase: false,
602
- });
603
- ```
604
-
605
- ## Advanced Usage
606
-
607
- ### Hybrid Setup (API Auth + Local Permissions)
608
-
609
- ```ts
610
- Identity.setup(container, {
611
- authProvider: "api",
612
- principalProvider: "local", // Use local DB for permissions
613
- useDatabase: true, // Keep DB for roles/permissions
614
- });
615
- ```
616
-
617
- ### Custom Provider
618
-
619
- You can create your own auth/principal provider:
620
-
621
- ```ts
622
- @injectable()
623
- class CustomAuthProvider implements Server.AuthProviderContract {
624
- async authenticate(player, credentials) {
625
- // Your custom logic
34
+ @Server.OnNet("admin:ban")
35
+ async handleBan(player: Server.Player, targetId: string) {
36
+ await this.accounts.ban(targetId, { reason: "Policy violation" });
626
37
  }
627
- // ... implement other methods
628
38
  }
629
-
630
- // Register manually
631
- container.registerSingleton("AuthProviderContract", CustomAuthProvider);
632
39
  ```
633
40
 
634
- ## Namespace Exports
635
-
636
- All exports are available under the `Identity` namespace:
637
-
638
- ```ts
639
- import { Identity } from "@open-core/identity";
640
-
641
- // Types
642
- Identity.Account;
643
- Identity.Role;
644
- Identity.SetupOptions;
645
-
646
- // Services
647
- Identity.AccountService;
648
- Identity.RoleService;
649
- Identity.MemoryCacheService;
650
-
651
- // Auth Providers
652
- Identity.LocalAuthProvider;
653
- Identity.CredentialsAuthProvider;
654
- Identity.ApiAuthProvider;
655
-
656
- // Principal Providers
657
- Identity.LocalPrincipalProvider;
658
- Identity.ApiPrincipalProvider;
659
-
660
- // Setup
661
- Identity.setup(container, options);
662
- ```
663
-
664
- ## Scripts
665
-
666
- ```bash
667
- # Build
668
- pnpm build
669
-
670
- # Lint
671
- pnpm lint
41
+ ## Installation & Setup
42
+
43
+ 1. **Implement your Store** (See [Contracts](./docs/contracts.md)):
44
+ ```ts
45
+ import { Identity, IdentityStore } from "@open-core/identity";
46
+
47
+ class MyStore extends IdentityStore { /* ... */ }
48
+
49
+ // Register it before installation
50
+ Identity.setIdentityStore(MyStore);
51
+ ```
52
+
53
+ 2. **Install the Plugin**:
54
+ ```ts
55
+ Identity.install({
56
+ auth: { mode: 'local', autoCreate: true },
57
+ principal: {
58
+ mode: 'roles',
59
+ roles: {
60
+ admin: { name: 'admin', rank: 100, permissions: ['*'] },
61
+ user: { name: 'user', rank: 0, permissions: ['chat.use'] }
62
+ }
63
+ }
64
+ });
65
+ ```
672
66
 
673
- # Lint and fix
674
- pnpm lint:fix
67
+ ## Exports
675
68
 
676
- # Clean build artifacts
677
- pnpm clean
678
- ```
69
+ The library only exports high-level components to keep your IDE suggestions clean:
70
+ - `Identity`: The main namespace for installation and registration.
71
+ - `AccountService`, `RoleService`: Public services for business logic.
72
+ - `IdentityStore`, `RoleStore`: Abstract contracts for persistence.
73
+ - `IDENTITY_OPTIONS`: Token for advanced DI usage.
74
+ - All relevant types and interfaces.
679
75
 
680
76
  ## License
681
77