@vunexa/lixa 0.1.4 → 0.1.6-alpha.10
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 +530 -15
- package/README.template.md +288 -44
- package/dist/dao/session-cache.d.ts +2 -1
- package/dist/dao/session-cache.d.ts.map +1 -1
- package/dist/dao/state-cache.d.ts +4 -0
- package/dist/dao/state-cache.d.ts.map +1 -1
- package/dist/dao/types.d.ts +68 -75
- package/dist/dao/types.d.ts.map +1 -1
- package/dist/errors.d.ts +90 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/export-types/index.d.ts +435 -107
- package/dist/index.cjs +590 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +411 -108
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +569 -94
- package/dist/index.js.map +1 -1
- package/dist/lixa.d.ts +57 -21
- package/dist/lixa.d.ts.map +1 -1
- package/dist/models/session.d.ts +17 -11
- package/dist/models/session.d.ts.map +1 -1
- package/dist/types.d.ts +39 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/cookies.d.ts +142 -0
- package/dist/utils/cookies.d.ts.map +1 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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.
|
|
@@ -187,15 +186,16 @@ app.get("/connect/github/callback", async (req, res) => {
|
|
|
187
186
|
res.redirect("/dashboard");
|
|
188
187
|
});
|
|
189
188
|
|
|
190
|
-
// 3. Query Connected Resource Access Token
|
|
189
|
+
// 3. Query Connected Resource Access Token (Auto-Refreshes Expired Tokens)
|
|
191
190
|
app.get("/api/github/repos", async (req, res) => {
|
|
191
|
+
// Queries by active session OR user ID, auto-refreshing expired access tokens
|
|
192
192
|
const resource = await lixa.getConnectedResource(req.cookies.session_id, "github");
|
|
193
193
|
|
|
194
194
|
if (!resource) {
|
|
195
195
|
return res.status(403).json({ error: "GitHub resource not connected" });
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
// Call GitHub API with resource access token
|
|
198
|
+
// Call GitHub API with active resource access token
|
|
199
199
|
const response = await fetch("https://api.github.com/user/repos", {
|
|
200
200
|
headers: { Authorization: `Bearer ${resource.accessToken}` },
|
|
201
201
|
});
|
|
@@ -204,7 +204,10 @@ app.get("/api/github/repos", async (req, res) => {
|
|
|
204
204
|
res.json(repos);
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
-
// 4.
|
|
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
|
|
208
211
|
app.delete("/connect/github", async (req, res) => {
|
|
209
212
|
await lixa.disconnectResource(req.cookies.session_id, "github");
|
|
210
213
|
res.json({ success: true });
|
|
@@ -220,26 +223,26 @@ export interface Session<TRaw = OAuthTokenResponse> {
|
|
|
220
223
|
/** Unique session ID generated by Lixa */
|
|
221
224
|
id?: string;
|
|
222
225
|
|
|
223
|
-
/** Primary access token or session token */
|
|
224
|
-
token: string;
|
|
225
|
-
|
|
226
226
|
/** Unified user ID across linked accounts */
|
|
227
227
|
userId?: string;
|
|
228
228
|
|
|
229
229
|
/** Primary user email */
|
|
230
230
|
email?: string;
|
|
231
231
|
|
|
232
|
-
/**
|
|
233
|
-
provider?: string;
|
|
234
|
-
|
|
235
|
-
/** Linked SSO provider accounts (AuthN) */
|
|
232
|
+
/** Linked SSO provider accounts (AuthN) - Single Source of Truth */
|
|
236
233
|
accounts?: Record<string, LinkedAccount>;
|
|
237
234
|
|
|
238
|
-
/** Connected third-party resource provider tokens (AuthZ) */
|
|
235
|
+
/** Connected third-party resource provider tokens (AuthZ) - Single Source of Truth */
|
|
239
236
|
resources?: Record<string, ConnectedResource>;
|
|
240
237
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
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;
|
|
243
246
|
}
|
|
244
247
|
```
|
|
245
248
|
|
|
@@ -252,6 +255,518 @@ export interface Session<TRaw = OAuthTokenResponse> {
|
|
|
252
255
|
|
|
253
256
|
---
|
|
254
257
|
|
|
258
|
+
## Custom Session Storage Implementations
|
|
259
|
+
|
|
260
|
+
Lixa allows you to store sessions in any database or cache by implementing the `SessionStorage` interface.
|
|
261
|
+
|
|
262
|
+
### 1. SQLite Session Storage (`better-sqlite3`)
|
|
263
|
+
|
|
264
|
+
For relational persistence or single-node deployments using SQLite:
|
|
265
|
+
|
|
266
|
+
#### Table Schema (SQL DDL)
|
|
267
|
+
|
|
268
|
+
```sql
|
|
269
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
270
|
+
id TEXT PRIMARY KEY,
|
|
271
|
+
user_email TEXT,
|
|
272
|
+
data TEXT NOT NULL,
|
|
273
|
+
expires_at INTEGER NOT NULL
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(user_email);
|
|
277
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
#### TypeScript Implementation
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import Database from "better-sqlite3";
|
|
284
|
+
import { Lixa, type SessionStorage, type Session } from "@vunexa/lixa";
|
|
285
|
+
|
|
286
|
+
export class SqliteSessionStorage implements SessionStorage {
|
|
287
|
+
private db = new Database("lixa_sessions.db");
|
|
288
|
+
|
|
289
|
+
constructor() {
|
|
290
|
+
this.db.exec(`
|
|
291
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
292
|
+
id TEXT PRIMARY KEY,
|
|
293
|
+
user_email TEXT,
|
|
294
|
+
data TEXT NOT NULL,
|
|
295
|
+
expires_at INTEGER NOT NULL
|
|
296
|
+
);
|
|
297
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(user_email);
|
|
298
|
+
`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async saveSession<T extends Session>(sessionId: string, session: T, expiresInSeconds: number): Promise<void> {
|
|
302
|
+
const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
|
303
|
+
const stmt = this.db.prepare(`
|
|
304
|
+
INSERT INTO sessions (id, user_email, data, expires_at)
|
|
305
|
+
VALUES (?, ?, ?, ?)
|
|
306
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
307
|
+
user_email = excluded.user_email,
|
|
308
|
+
data = excluded.data,
|
|
309
|
+
expires_at = excluded.expires_at
|
|
310
|
+
`);
|
|
311
|
+
stmt.run(sessionId, session.email || null, JSON.stringify(session), expiresAt);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async getSession<T extends Session>(sessionId: string): Promise<T | null> {
|
|
315
|
+
const now = Math.floor(Date.now() / 1000);
|
|
316
|
+
const stmt = this.db.prepare(`SELECT data FROM sessions WHERE id = ? AND expires_at > ?`);
|
|
317
|
+
const row = stmt.get(sessionId, now) as { data: string } | undefined;
|
|
318
|
+
return row ? (JSON.parse(row.data) as T) : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
322
|
+
const stmt = this.db.prepare(`DELETE FROM sessions WHERE id = ?`);
|
|
323
|
+
stmt.run(sessionId);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async getSessionByEmail<T extends Session>(email: string): Promise<{ sessionId: string; session: T } | null> {
|
|
327
|
+
const now = Math.floor(Date.now() / 1000);
|
|
328
|
+
const stmt = this.db.prepare(`SELECT id, data FROM sessions WHERE user_email = ? AND expires_at > ? LIMIT 1`);
|
|
329
|
+
const row = stmt.get(email, now) as { id: string; data: string } | undefined;
|
|
330
|
+
return row ? { sessionId: row.id, session: JSON.parse(row.data) as T } : null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Pass to Lixa instance
|
|
335
|
+
export const lixa = new Lixa({
|
|
336
|
+
sessionHandler: {
|
|
337
|
+
sessionStorage: new SqliteSessionStorage(),
|
|
338
|
+
},
|
|
339
|
+
providers: { /* ... */ },
|
|
340
|
+
});
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### Saved JSON Record Example in SQLite (`data` Column)
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"id": "e4a91f82c3b4a07f",
|
|
348
|
+
"userId": "1049281048",
|
|
349
|
+
"email": "alex.developer@example.com",
|
|
350
|
+
"accounts": {
|
|
351
|
+
"google": {
|
|
352
|
+
"provider": "google",
|
|
353
|
+
"email": "alex.developer@example.com",
|
|
354
|
+
"providerUserId": "1049281048",
|
|
355
|
+
"accessToken": "ya29.a0ARW5m7...",
|
|
356
|
+
"linkedAt": 1771657200000
|
|
357
|
+
},
|
|
358
|
+
"github": {
|
|
359
|
+
"provider": "github",
|
|
360
|
+
"email": "alex.developer@example.com",
|
|
361
|
+
"providerUserId": "5829104",
|
|
362
|
+
"accessToken": "gho_8f7b2a9e1c3...",
|
|
363
|
+
"linkedAt": 1771657250000
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### 2. AWS DynamoDB Session Storage (`@aws-sdk/lib-dynamodb`)
|
|
372
|
+
|
|
373
|
+
For serverless and distributed AWS deployments using DynamoDB:
|
|
374
|
+
|
|
375
|
+
#### Table Configuration
|
|
376
|
+
|
|
377
|
+
- **Table Name**: `LixaSessions`
|
|
378
|
+
- **Partition Key**: `sessionId` (String)
|
|
379
|
+
- **Global Secondary Index (GSI)**: `EmailIndex` (`email` as Partition Key)
|
|
380
|
+
- **TTL Attribute**: `ttl` (Unix timestamp in seconds for automatic AWS expiration)
|
|
381
|
+
|
|
382
|
+
#### TypeScript Implementation
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
386
|
+
import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
|
|
387
|
+
import { Lixa, type SessionStorage, type Session } from "@vunexa/lixa";
|
|
388
|
+
|
|
389
|
+
export class DynamoDbSessionStorage implements SessionStorage {
|
|
390
|
+
private docClient: DynamoDBDocumentClient;
|
|
391
|
+
private tableName = "LixaSessions";
|
|
392
|
+
|
|
393
|
+
constructor() {
|
|
394
|
+
const client = new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
|
|
395
|
+
this.docClient = DynamoDBDocumentClient.from(client);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async saveSession<T extends Session>(sessionId: string, session: T, expiresInSeconds: number): Promise<void> {
|
|
399
|
+
const ttl = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
|
400
|
+
await this.docClient.send(
|
|
401
|
+
new PutCommand({
|
|
402
|
+
TableName: this.tableName,
|
|
403
|
+
Item: {
|
|
404
|
+
sessionId,
|
|
405
|
+
email: session.email || "N/A",
|
|
406
|
+
sessionData: session,
|
|
407
|
+
ttl,
|
|
408
|
+
},
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async getSession<T extends Session>(sessionId: string): Promise<T | null> {
|
|
414
|
+
const res = await this.docClient.send(
|
|
415
|
+
new GetCommand({
|
|
416
|
+
TableName: this.tableName,
|
|
417
|
+
Key: { sessionId },
|
|
418
|
+
})
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
if (!res.Item) return null;
|
|
422
|
+
const now = Math.floor(Date.now() / 1000);
|
|
423
|
+
if (res.Item.ttl && res.Item.ttl < now) return null;
|
|
424
|
+
|
|
425
|
+
return res.Item.sessionData as T;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
429
|
+
await this.docClient.send(
|
|
430
|
+
new DeleteCommand({
|
|
431
|
+
TableName: this.tableName,
|
|
432
|
+
Key: { sessionId },
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async getSessionByEmail<T extends Session>(email: string): Promise<{ sessionId: string; session: T } | null> {
|
|
438
|
+
const res = await this.docClient.send(
|
|
439
|
+
new QueryCommand({
|
|
440
|
+
TableName: this.tableName,
|
|
441
|
+
IndexName: "EmailIndex",
|
|
442
|
+
KeyConditionExpression: "email = :email",
|
|
443
|
+
ExpressionAttributeValues: { ":email": email },
|
|
444
|
+
Limit: 1,
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
if (!res.Items || res.Items.length === 0) return null;
|
|
449
|
+
const item = res.Items[0];
|
|
450
|
+
const now = Math.floor(Date.now() / 1000);
|
|
451
|
+
if (item.ttl && item.ttl < now) return null;
|
|
452
|
+
|
|
453
|
+
return { sessionId: item.sessionId, session: item.sessionData as T };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Pass to Lixa instance
|
|
458
|
+
export const lixa = new Lixa({
|
|
459
|
+
sessionHandler: {
|
|
460
|
+
sessionStorage: new DynamoDbSessionStorage(),
|
|
461
|
+
},
|
|
462
|
+
providers: { /* ... */ },
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
#### Saved DynamoDB Item JSON Example
|
|
467
|
+
|
|
468
|
+
```json
|
|
469
|
+
{
|
|
470
|
+
"sessionId": "e4a91f82c3b4a07f",
|
|
471
|
+
"email": "alex.developer@example.com",
|
|
472
|
+
"ttl": 1771743600,
|
|
473
|
+
"sessionData": {
|
|
474
|
+
"id": "e4a91f82c3b4a07f",
|
|
475
|
+
"userId": "1049281048",
|
|
476
|
+
"email": "alex.developer@example.com",
|
|
477
|
+
"accounts": {
|
|
478
|
+
"google": {
|
|
479
|
+
"provider": "google",
|
|
480
|
+
"email": "alex.developer@example.com",
|
|
481
|
+
"providerUserId": "1049281048",
|
|
482
|
+
"accessToken": "ya29.a0ARW5m7...",
|
|
483
|
+
"linkedAt": 1771657200000
|
|
484
|
+
},
|
|
485
|
+
"github": {
|
|
486
|
+
"provider": "github",
|
|
487
|
+
"email": "alex.developer@example.com",
|
|
488
|
+
"providerUserId": "5829104",
|
|
489
|
+
"accessToken": "gho_8f7b2a9e1c3...",
|
|
490
|
+
"linkedAt": 1771657250000
|
|
491
|
+
}
|
|
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;
|
|
692
|
+
}
|
|
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
|
+
);
|
|
707
|
+
}
|
|
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
|
+
}
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
---
|
|
769
|
+
|
|
255
770
|
## User Info Utilities
|
|
256
771
|
|
|
257
772
|
Lixa provides utilities to extract user information from OAuth tokens:
|