@vunexa/lixa 0.1.6-alpha.1 → 0.1.6-alpha.11

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
@@ -17,8 +17,7 @@ A flexible, provider-agnostic OAuth 2.0 and OpenID Connect (OIDC) client library
17
17
  - **Multi-SSO Account Linking**: Seamlessly merge accounts sharing the same verified email address under a single unified user session.
18
18
  - **Post-Login Resource Connection API**: Connect third-party API providers (GitHub Repositories, Google Drive, Slack) post-authentication and manage resource tokens on the user session.
19
19
  - **Multi-provider OAuth/OIDC support** with unified API.
20
- - **Built-in providers** for Google, GitHub, and more via `@vunexa/lixa-providers`.
21
- - **Custom provider support** with extensible provider interface.
20
+ - **Pre-built Database Storage Adapters**: Official Prisma and Drizzle ORM adapters via `@vunexa/lixa-adapters` with support for PostgreSQL, MySQL, and SQLite.
22
21
  - **Unified session management** via `SessionHandler` (generation + storage).
23
22
  - **Unified state management** via `StateHandler` (PKCE + CSRF protection).
24
23
  - **Automatic PKCE** (Proof Key for Code Exchange) for all OAuth flows.
@@ -28,36 +27,7 @@ A flexible, provider-agnostic OAuth 2.0 and OpenID Connect (OIDC) client library
28
27
 
29
28
  ## Architecture Overview
30
29
 
31
- ```mermaid
32
- graph TD
33
- User([User Client / Browser])
34
-
35
- subgraph Lixa ["Lixa Core Engine"]
36
- AuthNGuard["Primary AuthN Guard<br/>(Minimal Identity Scopes Only)"]
37
- SessionMgr["Session Handler & Unified Session"]
38
- AccountLinker["Account Linker<br/>(AUTO_LINK_BY_VERIFIED_EMAIL)"]
39
- ResourceConn["Resource Connection API<br/>(Post-Login AuthZ)"]
40
- end
41
-
42
- subgraph IdentityProviders ["Identity Providers (AuthN)"]
43
- GoogleAuth["Google / OIDC"]
44
- GitHubAuth["GitHub OAuth"]
45
- end
46
-
47
- subgraph ResourceProviders ["Resource APIs (AuthZ)"]
48
- GitHubAPI["GitHub API (repos, orgs)"]
49
- GoogleDriveAPI["Google Drive API"]
50
- end
51
-
52
- User -->|"1. Primary Login / Link Account"| AuthNGuard
53
- AuthNGuard -->|"Request Identity"| IdentityProviders
54
- IdentityProviders -->|"Tokens + UserInfo"| AccountLinker
55
- AccountLinker -->|"Unified Session (session.accounts)"| SessionMgr
56
-
57
- User -->|"2. Connect Resource API (Post-Login)"| ResourceConn
58
- ResourceConn -->|"Request Permissions (repo, drive)"| ResourceProviders
59
- ResourceProviders -->|"Resource Access Tokens"| SessionMgr
60
- ```
30
+ ![Architecture Overview](https://cdn.jsdelivr.net/npm/@vunexa/lixa/docs/images/architecture.svg)
61
31
 
62
32
  ---
63
33
 
@@ -216,15 +186,16 @@ app.get("/connect/github/callback", async (req, res) => {
216
186
  res.redirect("/dashboard");
217
187
  });
218
188
 
219
- // 3. Query Connected Resource Access Token
189
+ // 3. Query Connected Resource Access Token (Auto-Refreshes Expired Tokens)
220
190
  app.get("/api/github/repos", async (req, res) => {
191
+ // Queries by active session OR user ID, auto-refreshing expired access tokens
221
192
  const resource = await lixa.getConnectedResource(req.cookies.session_id, "github");
222
193
 
223
194
  if (!resource) {
224
195
  return res.status(403).json({ error: "GitHub resource not connected" });
225
196
  }
226
197
 
227
- // Call GitHub API with resource access token
198
+ // Call GitHub API with active resource access token
228
199
  const response = await fetch("https://api.github.com/user/repos", {
229
200
  headers: { Authorization: `Bearer ${resource.accessToken}` },
230
201
  });
@@ -233,7 +204,10 @@ app.get("/api/github/repos", async (req, res) => {
233
204
  res.json(repos);
234
205
  });
235
206
 
236
- // 4. Disconnect Resource Provider
207
+ // 4. Query Resource Directly by User ID (e.g. inside background cron jobs or webhooks)
208
+ const githubResource = await lixa.getUserResource(userId, "github");
209
+
210
+ // 5. Disconnect Resource Provider
237
211
  app.delete("/connect/github", async (req, res) => {
238
212
  await lixa.disconnectResource(req.cookies.session_id, "github");
239
213
  res.json({ success: true });
@@ -249,26 +223,26 @@ export interface Session<TRaw = OAuthTokenResponse> {
249
223
  /** Unique session ID generated by Lixa */
250
224
  id?: string;
251
225
 
252
- /** Primary access token or session token */
253
- token: string;
254
-
255
226
  /** Unified user ID across linked accounts */
256
227
  userId?: string;
257
228
 
258
229
  /** Primary user email */
259
230
  email?: string;
260
231
 
261
- /** Current active auth provider */
262
- provider?: string;
263
-
264
- /** Linked SSO provider accounts (AuthN) */
232
+ /** Linked SSO provider accounts (AuthN) - Single Source of Truth */
265
233
  accounts?: Record<string, LinkedAccount>;
266
234
 
267
- /** Connected third-party resource provider tokens (AuthZ) */
235
+ /** Connected third-party resource provider tokens (AuthZ) - Single Source of Truth */
268
236
  resources?: Record<string, ConnectedResource>;
269
237
 
270
- /** Full raw token response from provider */
271
- raw: TRaw;
238
+ /** Optional primary access token or session token */
239
+ token?: string;
240
+
241
+ /** Optional current active auth provider */
242
+ provider?: string;
243
+
244
+ /** Optional raw token response from provider */
245
+ raw?: TRaw;
272
246
  }
273
247
  ```
274
248
 
@@ -371,10 +345,8 @@ export const lixa = new Lixa({
371
345
  ```json
372
346
  {
373
347
  "id": "e4a91f82c3b4a07f",
374
- "token": "ya29.a0ARW5m7...",
375
348
  "userId": "1049281048",
376
349
  "email": "alex.developer@example.com",
377
- "provider": "google",
378
350
  "accounts": {
379
351
  "google": {
380
352
  "provider": "google",
@@ -390,19 +362,6 @@ export const lixa = new Lixa({
390
362
  "accessToken": "gho_8f7b2a9e1c3...",
391
363
  "linkedAt": 1771657250000
392
364
  }
393
- },
394
- "resources": {
395
- "github": {
396
- "provider": "github",
397
- "accessToken": "gho_resource_repo_9a8b7c...",
398
- "scopes": ["repo", "read:org"],
399
- "connectedAt": 1771657300000
400
- }
401
- },
402
- "raw": {
403
- "access_token": "ya29.a0ARW5m7...",
404
- "token_type": "Bearer",
405
- "expires_in": 3599
406
365
  }
407
366
  }
408
367
  ```
@@ -513,10 +472,8 @@ export const lixa = new Lixa({
513
472
  "ttl": 1771743600,
514
473
  "sessionData": {
515
474
  "id": "e4a91f82c3b4a07f",
516
- "token": "ya29.a0ARW5m7...",
517
475
  "userId": "1049281048",
518
476
  "email": "alex.developer@example.com",
519
- "provider": "google",
520
477
  "accounts": {
521
478
  "google": {
522
479
  "provider": "google",
@@ -532,22 +489,280 @@ export const lixa = new Lixa({
532
489
  "accessToken": "gho_8f7b2a9e1c3...",
533
490
  "linkedAt": 1771657250000
534
491
  }
535
- },
536
- "resources": {
537
- "github": {
538
- "provider": "github",
539
- "accessToken": "gho_resource_repo_9a8b7c...",
540
- "scopes": ["repo", "read:org"],
541
- "connectedAt": 1771657300000
492
+ }
493
+ }
494
+ }
495
+ ```
496
+
497
+ ---
498
+
499
+ ## Custom Resource Storage Implementation (`ResourceStorage`)
500
+
501
+ While `SessionStorage` manages short-lived user authentication sessions (indexed by `sessionId`), **`ResourceStorage`** manages long-lived third-party API tokens (**AuthZ**) bound directly to a **User ID** (or Email). This ensures that third-party credentials (such as GitHub Repositories or Google Drive) persist across session expirations and logouts.
502
+
503
+ ### `ResourceStorage` Interface
504
+
505
+ ```typescript
506
+ export interface ResourceStorage {
507
+ saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void>;
508
+ getResource(userId: string, provider: string): Promise<ConnectedResource | null>;
509
+ getUserResources(userId: string): Promise<Record<string, ConnectedResource>>;
510
+ deleteResource(userId: string, provider: string): Promise<void>;
511
+ }
512
+ ```
513
+
514
+ ### 1. SQLite Resource Storage (`better-sqlite3`)
515
+
516
+ #### Table Schema (SQL DDL)
517
+
518
+ ```sql
519
+ CREATE TABLE IF NOT EXISTS user_resources (
520
+ user_id TEXT NOT NULL,
521
+ provider TEXT NOT NULL,
522
+ data TEXT NOT NULL,
523
+ updated_at INTEGER NOT NULL,
524
+ PRIMARY KEY (user_id, provider)
525
+ );
526
+ ```
527
+
528
+ #### TypeScript Implementation
529
+
530
+ ```typescript
531
+ import Database from "better-sqlite3";
532
+ import { Lixa, type ResourceStorage, type ConnectedResource } from "@vunexa/lixa";
533
+
534
+ export class SqliteResourceStorage implements ResourceStorage {
535
+ private db = new Database("lixa_resources.db");
536
+
537
+ constructor() {
538
+ this.db.exec(`
539
+ CREATE TABLE IF NOT EXISTS user_resources (
540
+ user_id TEXT NOT NULL,
541
+ provider TEXT NOT NULL,
542
+ data TEXT NOT NULL,
543
+ updated_at INTEGER NOT NULL,
544
+ PRIMARY KEY (user_id, provider)
545
+ );
546
+ `);
547
+ }
548
+
549
+ async saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void> {
550
+ const stmt = this.db.prepare(`
551
+ INSERT INTO user_resources (user_id, provider, data, updated_at)
552
+ VALUES (?, ?, ?, ?)
553
+ ON CONFLICT(user_id, provider) DO UPDATE SET
554
+ data = excluded.data,
555
+ updated_at = excluded.updated_at
556
+ `);
557
+ stmt.run(userId, provider.toLowerCase(), JSON.stringify(resource), Date.now());
558
+ }
559
+
560
+ async getResource(userId: string, provider: string): Promise<ConnectedResource | null> {
561
+ const stmt = this.db.prepare(`SELECT data FROM user_resources WHERE user_id = ? AND provider = ?`);
562
+ const row = stmt.get(userId, provider.toLowerCase()) as { data: string } | undefined;
563
+ return row ? (JSON.parse(row.data) as ConnectedResource) : null;
564
+ }
565
+
566
+ async getUserResources(userId: string): Promise<Record<string, ConnectedResource>> {
567
+ const stmt = this.db.prepare(`SELECT provider, data FROM user_resources WHERE user_id = ?`);
568
+ const rows = stmt.all(userId) as Array<{ provider: string; data: string }>;
569
+ const result: Record<string, ConnectedResource> = {};
570
+ for (const r of rows) {
571
+ result[r.provider] = JSON.parse(r.data);
572
+ }
573
+ return result;
574
+ }
575
+
576
+ async deleteResource(userId: string, provider: string): Promise<void> {
577
+ const stmt = this.db.prepare(`DELETE FROM user_resources WHERE user_id = ? AND provider = ?`);
578
+ stmt.run(userId, provider.toLowerCase());
579
+ }
580
+ }
581
+
582
+ // Pass to Lixa instance via resourceHandler
583
+ export const lixa = new Lixa({
584
+ resourceHandler: {
585
+ resourceStorage: new SqliteResourceStorage(),
586
+ },
587
+ providers: { /* ... */ },
588
+ });
589
+ ```
590
+
591
+ #### Saved JSON Records Example in SQLite (`user_resources` Table)
592
+
593
+ **Row 1 (GitHub Repositories)**:
594
+ ```json
595
+ {
596
+ "user_id": "1049281048",
597
+ "provider": "github",
598
+ "data": {
599
+ "accessToken": "gho_resource_repo_9a8b7c...",
600
+ "refreshToken": "ghr_refresh_token_123...",
601
+ "scopes": ["repo", "read:org"],
602
+ "expiresAt": 1771743600000,
603
+ "connectedAt": 1771657300000
604
+ }
605
+ }
606
+ ```
607
+
608
+ **Row 2 (Google Drive)**:
609
+ ```json
610
+ {
611
+ "user_id": "1049281048",
612
+ "provider": "google",
613
+ "data": {
614
+ "accessToken": "ya29.drive_resource_token_456...",
615
+ "refreshToken": "1//09abc_google_refresh_token...",
616
+ "scopes": ["https://www.googleapis.com/auth/drive.readonly"],
617
+ "expiresAt": 1771660800000,
618
+ "connectedAt": 1771657400000
619
+ }
620
+ }
621
+ ```
622
+
623
+ ---
624
+
625
+ ### 2. AWS DynamoDB Resource Storage (`@aws-sdk/lib-dynamodb`)
626
+
627
+ For serverless AWS deployments storing user API credentials in DynamoDB:
628
+
629
+ #### Table Configuration
630
+
631
+ - **Table Name**: `LixaUserResources`
632
+ - **Partition Key (PK)**: `userId` (String)
633
+ - **Sort Key (SK)**: `provider` (String)
634
+
635
+ #### TypeScript Implementation
636
+
637
+ ```typescript
638
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
639
+ import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
640
+ import { Lixa, type ResourceStorage, type ConnectedResource } from "@vunexa/lixa";
641
+
642
+ export class DynamoDbResourceStorage implements ResourceStorage {
643
+ private docClient: DynamoDBDocumentClient;
644
+ private tableName = "LixaUserResources";
645
+
646
+ constructor() {
647
+ const client = new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
648
+ this.docClient = DynamoDBDocumentClient.from(client);
649
+ }
650
+
651
+ async saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void> {
652
+ await this.docClient.send(
653
+ new PutCommand({
654
+ TableName: this.tableName,
655
+ Item: {
656
+ userId,
657
+ provider: provider.toLowerCase(),
658
+ resourceData: resource,
659
+ updatedAt: Date.now(),
660
+ },
661
+ })
662
+ );
663
+ }
664
+
665
+ async getResource(userId: string, provider: string): Promise<ConnectedResource | null> {
666
+ const res = await this.docClient.send(
667
+ new GetCommand({
668
+ TableName: this.tableName,
669
+ Key: {
670
+ userId,
671
+ provider: provider.toLowerCase(),
672
+ },
673
+ })
674
+ );
675
+
676
+ return res.Item ? (res.Item.resourceData as ConnectedResource) : null;
677
+ }
678
+
679
+ async getUserResources(userId: string): Promise<Record<string, ConnectedResource>> {
680
+ const res = await this.docClient.send(
681
+ new QueryCommand({
682
+ TableName: this.tableName,
683
+ KeyConditionExpression: "userId = :userId",
684
+ ExpressionAttributeValues: { ":userId": userId },
685
+ })
686
+ );
687
+
688
+ const result: Record<string, ConnectedResource> = {};
689
+ if (res.Items) {
690
+ for (const item of res.Items) {
691
+ result[item.provider] = item.resourceData as ConnectedResource;
542
692
  }
543
- },
544
- "raw": {
545
- "access_token": "ya29.a0ARW5m7...",
546
- "token_type": "Bearer",
547
- "expires_in": 3599
548
693
  }
694
+ return result;
695
+ }
696
+
697
+ async deleteResource(userId: string, provider: string): Promise<void> {
698
+ await this.docClient.send(
699
+ new DeleteCommand({
700
+ TableName: this.tableName,
701
+ Key: {
702
+ userId,
703
+ provider: provider.toLowerCase(),
704
+ },
705
+ })
706
+ );
549
707
  }
550
708
  }
709
+
710
+ // Pass to Lixa instance via resourceHandler
711
+ export const lixa = new Lixa({
712
+ resourceHandler: {
713
+ resourceStorage: new DynamoDbResourceStorage(),
714
+ },
715
+ providers: { /* ... */ },
716
+ });
717
+ ```
718
+
719
+ #### Saved DynamoDB Resource Items Example (`LixaUserResources` Table)
720
+
721
+ ```json
722
+ [
723
+ {
724
+ "userId": "1049281048",
725
+ "provider": "github",
726
+ "updatedAt": 1771657300000,
727
+ "resourceData": {
728
+ "accessToken": "gho_resource_repo_9a8b7c...",
729
+ "refreshToken": "ghr_refresh_token_123...",
730
+ "scopes": ["repo", "read:org"],
731
+ "expiresAt": 1771743600000,
732
+ "connectedAt": 1771657300000
733
+ }
734
+ },
735
+ {
736
+ "userId": "1049281048",
737
+ "provider": "google",
738
+ "updatedAt": 1771657400000,
739
+ "resourceData": {
740
+ "accessToken": "ya29.drive_resource_token_456...",
741
+ "refreshToken": "1//09abc_google_refresh_token...",
742
+ "scopes": ["https://www.googleapis.com/auth/drive.readonly"],
743
+ "expiresAt": 1771660800000,
744
+ "connectedAt": 1771657400000
745
+ }
746
+ }
747
+ ]
748
+ ```
749
+
750
+ ---
751
+
752
+ ### 3. Querying Connected Resources Directly by User ID
753
+
754
+ `ResourceStorage` enables background workers, cron jobs, and webhooks to access third-party API credentials by `userId` without an active HTTP session:
755
+
756
+ ```typescript
757
+ // Background worker querying user's GitHub Repos token
758
+ const githubResource = await lixa.getUserResource(user.id, "github");
759
+
760
+ if (githubResource) {
761
+ // Lixa auto-refreshes expired access tokens transparently!
762
+ const response = await fetch("https://api.github.com/user/repos", {
763
+ headers: { Authorization: `Bearer ${githubResource.accessToken}` },
764
+ });
765
+ }
551
766
  ```
552
767
 
553
768
  ---