@vunexa/lixa 0.1.6-alpha.13 → 0.1.6-alpha.15
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 +392 -674
- package/dist/credentials/credentials-manager.d.ts +14 -0
- package/dist/credentials/credentials-manager.d.ts.map +1 -1
- package/dist/credentials/types.d.ts +29 -6
- package/dist/credentials/types.d.ts.map +1 -1
- package/dist/dao/session-cache.d.ts.map +1 -1
- package/dist/dao/types.d.ts +24 -0
- package/dist/dao/types.d.ts.map +1 -1
- package/dist/errors.d.ts +62 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/export-types/index.d.ts +899 -88
- package/dist/identity/composite.d.ts +65 -0
- package/dist/identity/composite.d.ts.map +1 -0
- package/dist/identity/index.d.ts +4 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/self-managed.d.ts +66 -0
- package/dist/identity/self-managed.d.ts.map +1 -0
- package/dist/identity/types.d.ts +232 -0
- package/dist/identity/types.d.ts.map +1 -0
- package/dist/index.cjs +1543 -341
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +868 -80
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1461 -267
- package/dist/index.js.map +1 -1
- package/dist/lixa.d.ts +190 -35
- package/dist/lixa.d.ts.map +1 -1
- package/dist/models/session.d.ts +12 -6
- package/dist/models/session.d.ts.map +1 -1
- package/dist/providers/IProvider.d.ts +4 -0
- package/dist/providers/IProvider.d.ts.map +1 -1
- package/dist/types.d.ts +140 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/user-info.d.ts.map +1 -1
- package/package.json +3 -11
package/README.md
CHANGED
|
@@ -1,785 +1,503 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/vunexa/lixa/master/docs/assets/lixa-banner.png" alt="Lixa Banner" width="600" onerror="this.style.display='none'"/>
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
# 🚀 Lixa (`@vunexa/lixa`)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
> **The modern, developer-first TypeScript authentication & authorization framework for Node.js.**
|
|
8
|
+
> Built for zero-friction developer experience, strict AuthN/AuthZ separation, multi-SSO account linking, normalized database persistence, and blazing-fast in-memory caching.
|
|
9
|
+
|
|
10
|
+
[](https://www.npmjs.com/package/@vunexa/lixa)
|
|
6
11
|
[](https://www.npmjs.com/package/@vunexa/lixa)
|
|
7
12
|
[](https://opensource.org/licenses/MIT)
|
|
8
|
-
|
|
9
|
-
A flexible, provider-agnostic OAuth 2.0 and OpenID Connect (OIDC) client library for backend applications.
|
|
10
|
-
`@vunexa/lixa` simplifies multi-provider authentication (e.g. Google, GitHub, Microsoft), enforces strict **AuthN/AuthZ separation**, enables **multi-SSO account linking**, and provides a dedicated post-login **Resource Connection API**.
|
|
13
|
+
[](https://www.typescriptlang.org/)
|
|
11
14
|
|
|
12
15
|
---
|
|
13
16
|
|
|
14
|
-
##
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
17
|
+
## 📑 Table of Contents
|
|
18
|
+
|
|
19
|
+
- [Why Lixa?](#-why-lixa)
|
|
20
|
+
- [Key Features](#-key-features)
|
|
21
|
+
- [Architecture & Sequence Diagrams](#-architecture--sequence-diagrams)
|
|
22
|
+
- [1. Authentication Flow (AuthN)](#1-authentication-flow-authn)
|
|
23
|
+
- [2. Post-Login Resource Connection (AuthZ)](#2-post-login-resource-connection-authz)
|
|
24
|
+
- [3. Multi-SSO Account Linking](#3-multi-sso-account-linking)
|
|
25
|
+
- [Installation](#-installation)
|
|
26
|
+
- [Quickstart: AWS Cognito + Express + Prisma](#-quickstart-aws-cognito--express--prisma)
|
|
27
|
+
- [Step 1: Database Schema (Prisma)](#step-1-database-schema-prisma)
|
|
28
|
+
- [Step 2: Initialize Lixa & Storage with 2-Minute Cache](#step-2-initialize-lixa--storage-with-2-minute-cache)
|
|
29
|
+
- [Step 3: Setup Express Server & Routes](#step-3-setup-express-server--routes)
|
|
30
|
+
- [Step 4: Protect Routes & Use Tokens](#step-4-protect-routes--use-tokens)
|
|
31
|
+
- [Connecting External Resources (AuthZ)](#-connecting-external-resources-authz)
|
|
32
|
+
- [In-Memory 2-Minute Caching (`withLocalCache`)](#-in-memory-2-minute-caching-withlocalcache)
|
|
33
|
+
- [Supported Identity Providers](#-supported-identity-providers)
|
|
34
|
+
- [TypeScript API Reference](#-typescript-api-reference)
|
|
35
|
+
- [Contributing & License](#-contributing--license)
|
|
25
36
|
|
|
26
37
|
---
|
|
27
38
|
|
|
28
|
-
##
|
|
39
|
+
## 💡 Why Lixa?
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
Most auth libraries blur the line between **authenticating a user** (AuthN) and **requesting access to third-party APIs** (AuthZ). They cram bloated, multi-token JSON blobs into session cookies or single database rows, making user sessions fragile, bloated, and vulnerable.
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# or
|
|
39
|
-
yarn add @vunexa/lixa @vunexa/lixa-extensions
|
|
40
|
-
```
|
|
43
|
+
**Lixa solves this cleanly:**
|
|
44
|
+
1. **Single-Purpose Sessions**: A user session contains *only* the authentication tokens (`accessToken`, `refreshToken`, `idToken`) needed for that specific session.
|
|
45
|
+
2. **First-Class Normalized Database Columns**: No opaque JSON blobs. Every table (`sessions`, `user_accounts`, `user_resources`, `oauth_states`, `user_credentials`) uses primitive typed columns (`userId`, `provider`, `accessToken`, `expiresAt`, etc.) with built-in database indexing.
|
|
46
|
+
3. **Built-in 2-Minute Local In-Memory Cache**: Eliminates redundant database reads on high-frequency API endpoints while guaranteeing safe invalidation on logout or token updates.
|
|
47
|
+
4. **Universal Multi-SSO Account Linking**: Seamlessly links AWS Cognito, Google, GitHub, Okta, Auth0, or username/password credentials to a single unified user ID by verified email.
|
|
48
|
+
5. **Zero-Boilerplate DevXP**: Type-safe out of the box, with full Express middleware, Prisma and Drizzle ORM adapters, and automatic PKCE security.
|
|
41
49
|
|
|
42
50
|
---
|
|
43
51
|
|
|
44
|
-
##
|
|
52
|
+
## ✨ Key Features
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
- 🛡️ **Strict AuthN / AuthZ Separation**: Identity tokens stay in `sessions`. External API tokens (e.g. GitHub repos, Google Drive) are isolated in `user_resources`.
|
|
55
|
+
- ⚡ **2-Minute In-Memory Cache (`withLocalCache`)**: Blazing-fast response times for session checks and user lookups with zero SQLite/Postgres I/O overhead.
|
|
56
|
+
- 🔗 **Multi-SSO Account Linking**: Automatic or interactive linking of multiple identity providers under one user account.
|
|
57
|
+
- 🗄️ **Clean Database Schema**: Fully normalized Prisma and Drizzle models with primitive column types and indexed foreign keys.
|
|
58
|
+
- 🔐 **PKCE & State Validation**: Automatic RFC 7636 Proof Key for Code Exchange and cryptographic CSRF state verification.
|
|
59
|
+
- ☁️ **Enterprise Identity Backends**: First-class support for **AWS Cognito**, **Auth0**, **Okta**, and **Self-Managed Database** credentials.
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
import { Lixa, AccountLinkingStrategy } from "@vunexa/lixa";
|
|
50
|
-
import { GoogleProvider, GithubProvider } from "@vunexa/lixa-extensions/providers";
|
|
61
|
+
---
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
## 📐 Architecture & Sequence Diagrams
|
|
64
|
+
|
|
65
|
+
### 1. Authentication Flow (AuthN)
|
|
66
|
+
|
|
67
|
+
This sequence diagram illustrates how a user logs in via AWS Cognito (or Google/GitHub SSO), receives a secure session cookie, and benefits from Lixa's 2-minute memory cache:
|
|
68
|
+
|
|
69
|
+
```mermaid
|
|
70
|
+
sequenceDiagram
|
|
71
|
+
autonumber
|
|
72
|
+
actor User as User Browser
|
|
73
|
+
participant Express as Express App (Lixa Middleware)
|
|
74
|
+
participant Lixa as Lixa Engine
|
|
75
|
+
participant Cache as In-Memory Cache (120s TTL)
|
|
76
|
+
participant DB as Prisma / Database
|
|
77
|
+
participant Cognito as AWS Cognito (OIDC / Hosted UI)
|
|
78
|
+
|
|
79
|
+
User->>Express: GET /api/v1/auth/login?provider=cognito
|
|
80
|
+
Express->>Lixa: getAuthUrl("cognito")
|
|
81
|
+
Lixa->>DB: Store state & PKCE codeVerifier in `oauth_states`
|
|
82
|
+
Lixa-->>Express: Return Authorization URL
|
|
83
|
+
Express-->>User: 302 Redirect to AWS Cognito
|
|
84
|
+
|
|
85
|
+
User->>Cognito: User signs in & consents
|
|
86
|
+
Cognito-->>User: 302 Redirect to /api/v1/auth/callback?code=...&state=...
|
|
87
|
+
|
|
88
|
+
User->>Express: GET /api/v1/auth/callback?code=...&state=...
|
|
89
|
+
Express->>Lixa: handleCallback("cognito", code, state)
|
|
90
|
+
Lixa->>DB: Validate & delete state from `oauth_states`
|
|
91
|
+
Lixa->>Cognito: Exchange code + PKCE verifier for tokens (AT, RT, idToken)
|
|
92
|
+
Cognito-->>Lixa: Return Cognito Tokens & User Claims
|
|
93
|
+
Lixa->>DB: Upsert normalized row in `sessions` (userId, AT, RT, idToken)
|
|
94
|
+
Lixa->>Cache: Cache session for 120 seconds
|
|
95
|
+
Lixa-->>Express: Issue Session ID (UUID)
|
|
96
|
+
Express-->>User: Set-Cookie: app_session=<sessionId>; HttpOnly; Secure
|
|
97
|
+
|
|
98
|
+
Note over User, Express: Subsequent Authenticated Requests
|
|
99
|
+
User->>Express: GET /api/v1/me (Cookie: app_session=...)
|
|
100
|
+
Express->>Cache: Check session in memory cache
|
|
101
|
+
alt Cache Hit (< 2 mins)
|
|
102
|
+
Cache-->>Express: Return cached session (0 DB queries!)
|
|
103
|
+
else Cache Miss / Expired
|
|
104
|
+
Express->>DB: Query `sessions` by id
|
|
105
|
+
DB-->>Express: Return session & refresh cache
|
|
106
|
+
end
|
|
107
|
+
Express-->>User: 200 OK (User Profile)
|
|
75
108
|
```
|
|
76
109
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
// 1. Redirect to provider authorization URL
|
|
81
|
-
app.get("/auth/:provider", async (req, res) => {
|
|
82
|
-
const authUrl = await lixa.getAuthUrl(req.params.provider);
|
|
83
|
-
res.redirect(authUrl);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// 2. Handle provider callback
|
|
87
|
-
app.get("/auth/:provider/callback", async (req, res) => {
|
|
88
|
-
const { code, state } = req.query;
|
|
89
|
-
const provider = req.params.provider;
|
|
110
|
+
---
|
|
90
111
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
### 2. Post-Login Resource Connection (AuthZ)
|
|
113
|
+
|
|
114
|
+
When an authenticated user grants your application access to external APIs (e.g. GitHub repos or Google Drive), tokens are stored in `user_resources` and bound to the user's account—completely separate from their login session:
|
|
115
|
+
|
|
116
|
+
```mermaid
|
|
117
|
+
sequenceDiagram
|
|
118
|
+
autonumber
|
|
119
|
+
actor User as Authenticated User
|
|
120
|
+
participant Express as Express App
|
|
121
|
+
participant Lixa as Lixa Engine
|
|
122
|
+
participant DB as Database (`user_resources`)
|
|
123
|
+
participant GitHub as GitHub OAuth (Resource Provider)
|
|
124
|
+
|
|
125
|
+
User->>Express: GET /api/v1/resources/connect/github
|
|
126
|
+
Express->>Lixa: getResourceAuthUrl("github", { userId: req.session.userId })
|
|
127
|
+
Lixa-->>Express: Return OAuth URL with resource scopes (`repo`, `read:user`)
|
|
128
|
+
Express-->>User: 302 Redirect to GitHub OAuth
|
|
129
|
+
|
|
130
|
+
User->>GitHub: Authorize application for repository access
|
|
131
|
+
GitHub-->>User: 302 Redirect to /api/v1/resources/callback/github?code=...
|
|
132
|
+
|
|
133
|
+
User->>Express: GET /api/v1/resources/callback/github?code=...
|
|
134
|
+
Express->>Lixa: handleResourceCallback("github", code, userId)
|
|
135
|
+
Lixa->>GitHub: Exchange authorization code for Resource Access Token
|
|
136
|
+
GitHub-->>Lixa: Return Access Token & Scopes
|
|
137
|
+
Lixa->>DB: Save to `user_resources` (userId, provider="github", accessToken, scopes)
|
|
138
|
+
Lixa-->>Express: Resource Connected
|
|
139
|
+
Express-->>User: 200 OK: GitHub Connected Successfully!
|
|
140
|
+
|
|
141
|
+
Note over Express, GitHub: Background Tasks / API Requests
|
|
142
|
+
Express->>DB: Get GitHub Resource Token for userId
|
|
143
|
+
Express->>GitHub: Fetch user repositories with Resource Token
|
|
144
|
+
GitHub-->>Express: Repository List
|
|
104
145
|
```
|
|
105
146
|
|
|
106
147
|
---
|
|
107
148
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
Lixa supports automatic identity merging for users signing in with different SSO providers (e.g. Google and GitHub) that share the same verified email address.
|
|
111
|
-
|
|
112
|
-
### Account Linking Sequence Diagram
|
|
113
|
-
|
|
114
|
-

|
|
115
|
-
|
|
116
|
-
### Account Linking Configuration Modes
|
|
117
|
-
|
|
118
|
-
```typescript
|
|
119
|
-
import { AccountLinkingStrategy } from "@vunexa/lixa";
|
|
120
|
-
|
|
121
|
-
// Mode 1: Auto-link by verified email (Recommended)
|
|
122
|
-
accountLinking: {
|
|
123
|
-
mode: AccountLinkingStrategy.AUTO_LINK_BY_VERIFIED_EMAIL,
|
|
124
|
-
requireVerifiedEmail: true,
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Mode 2: Treat provider logins as separate accounts
|
|
128
|
-
accountLinking: {
|
|
129
|
-
mode: AccountLinkingStrategy.ISOLATED,
|
|
130
|
-
}
|
|
131
|
-
```
|
|
149
|
+
### 3. Multi-SSO Account Linking
|
|
132
150
|
|
|
133
|
-
|
|
151
|
+
Lixa automatically links multiple identity providers (e.g. AWS Cognito + Google) sharing the same verified email address:
|
|
134
152
|
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
state: req.query.state as string,
|
|
142
|
-
});
|
|
153
|
+
```mermaid
|
|
154
|
+
sequenceDiagram
|
|
155
|
+
autonumber
|
|
156
|
+
actor User as User with alex@example.com
|
|
157
|
+
participant Lixa as Lixa Engine
|
|
158
|
+
participant DB as Database (`user_accounts`)
|
|
143
159
|
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
Note over User, DB: User previously registered with AWS Cognito (userId="usr_123")
|
|
161
|
+
User->>Lixa: Signs in via Google SSO (email="alex@example.com", verified=true)
|
|
162
|
+
Lixa->>DB: Find existing account by verified email in `user_accounts`
|
|
163
|
+
DB-->>Lixa: Found existing account (userId="usr_123", provider="cognito")
|
|
164
|
+
Lixa->>DB: Link Google identity to same userId in `user_accounts` (userId="usr_123", provider="google")
|
|
165
|
+
Lixa->>DB: Create active session for userId="usr_123"
|
|
166
|
+
Lixa-->>User: Logged in! Unified profile and data preserved across both logins.
|
|
146
167
|
```
|
|
147
168
|
|
|
148
169
|
---
|
|
149
170
|
|
|
150
|
-
##
|
|
151
|
-
|
|
152
|
-
Primary authentication is strictly limited to identity scopes. To request third-party API permissions (such as GitHub Repositories or Google Drive), use Lixa's post-login **Resource Connection API**.
|
|
153
|
-
|
|
154
|
-
### Resource Connection Sequence Diagram
|
|
171
|
+
## 📦 Installation
|
|
155
172
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
```typescript
|
|
161
|
-
// 1. Generate Resource Authorization URL (Requires Active Session)
|
|
162
|
-
app.get("/connect/github", async (req, res) => {
|
|
163
|
-
const sessionId = req.cookies.session_id;
|
|
164
|
-
|
|
165
|
-
const resourceAuthUrl = await lixa.getResourceAuthUrl({
|
|
166
|
-
sessionId,
|
|
167
|
-
provider: "github",
|
|
168
|
-
scopes: ["repo", "read:org"], // Resource permissions requested post-login
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
res.redirect(resourceAuthUrl);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// 2. Handle Resource Callback
|
|
175
|
-
app.get("/connect/github/callback", async (req, res) => {
|
|
176
|
-
const sessionId = req.cookies.session_id;
|
|
177
|
-
|
|
178
|
-
const updatedSession = await lixa.handleResourceCallback({
|
|
179
|
-
sessionId,
|
|
180
|
-
provider: "github",
|
|
181
|
-
code: req.query.code as string,
|
|
182
|
-
state: req.query.state as string,
|
|
183
|
-
scopes: ["repo", "read:org"],
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
res.redirect("/dashboard");
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// 3. Query Connected Resource Access Token (Auto-Refreshes Expired Tokens)
|
|
190
|
-
app.get("/api/github/repos", async (req, res) => {
|
|
191
|
-
// Queries by active session OR user ID, auto-refreshing expired access tokens
|
|
192
|
-
const resource = await lixa.getConnectedResource(req.cookies.session_id, "github");
|
|
193
|
-
|
|
194
|
-
if (!resource) {
|
|
195
|
-
return res.status(403).json({ error: "GitHub resource not connected" });
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Call GitHub API with active resource access token
|
|
199
|
-
const response = await fetch("https://api.github.com/user/repos", {
|
|
200
|
-
headers: { Authorization: `Bearer ${resource.accessToken}` },
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
const repos = await response.json();
|
|
204
|
-
res.json(repos);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
// 4. Query Resource Directly by User ID (e.g. inside background cron jobs or webhooks)
|
|
208
|
-
const githubResource = await lixa.getUserResource(userId, "github");
|
|
173
|
+
```bash
|
|
174
|
+
# Core framework & official extensions
|
|
175
|
+
npm install @vunexa/lixa @vunexa/lixa-extensions
|
|
209
176
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
res.json({ success: true });
|
|
214
|
-
});
|
|
177
|
+
# If using Prisma ORM (recommended)
|
|
178
|
+
npm install @prisma/client
|
|
179
|
+
npm install -D prisma
|
|
215
180
|
```
|
|
216
181
|
|
|
217
182
|
---
|
|
218
183
|
|
|
219
|
-
##
|
|
184
|
+
## 🏁 Quickstart: AWS Cognito + Express + Prisma
|
|
220
185
|
|
|
221
|
-
|
|
222
|
-
export interface Session<TRaw = OAuthTokenResponse> {
|
|
223
|
-
/** Unique session ID generated by Lixa */
|
|
224
|
-
id?: string;
|
|
186
|
+
Here is a complete, production-ready example configuring **AWS Cognito SSO**, **Prisma storage with 2-minute caching**, and **Express**.
|
|
225
187
|
|
|
226
|
-
|
|
227
|
-
userId?: string;
|
|
188
|
+
### Step 1: Database Schema (Prisma)
|
|
228
189
|
|
|
229
|
-
|
|
230
|
-
email?: string;
|
|
190
|
+
Create or update your `prisma/schema.prisma` file with normalized primitive columns:
|
|
231
191
|
|
|
232
|
-
|
|
233
|
-
|
|
192
|
+
```prisma
|
|
193
|
+
datasource db {
|
|
194
|
+
provider = "sqlite" // or "postgresql", "mysql"
|
|
195
|
+
url = env("DATABASE_URL")
|
|
196
|
+
}
|
|
234
197
|
|
|
235
|
-
|
|
236
|
-
|
|
198
|
+
generator client {
|
|
199
|
+
provider = "prisma-client-js"
|
|
200
|
+
}
|
|
237
201
|
|
|
238
|
-
|
|
239
|
-
|
|
202
|
+
// 1. Active User Sessions (AuthN)
|
|
203
|
+
model Session {
|
|
204
|
+
id String @id @map("id")
|
|
205
|
+
userId String? @map("user_id")
|
|
206
|
+
userEmail String? @map("user_email")
|
|
207
|
+
provider String? @map("provider")
|
|
208
|
+
accessToken String? @map("access_token")
|
|
209
|
+
refreshToken String? @map("refresh_token")
|
|
210
|
+
idToken String? @map("id_token")
|
|
211
|
+
expiresAt DateTime @map("expires_at")
|
|
212
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
213
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
214
|
+
|
|
215
|
+
@@index([userId])
|
|
216
|
+
@@index([userEmail])
|
|
217
|
+
@@index([expiresAt])
|
|
218
|
+
@@map("sessions")
|
|
219
|
+
}
|
|
240
220
|
|
|
241
|
-
|
|
242
|
-
|
|
221
|
+
// 2. Multi-SSO Linked Identity Accounts (AuthN)
|
|
222
|
+
model UserAccount {
|
|
223
|
+
userId String @map("user_id")
|
|
224
|
+
provider String @map("provider")
|
|
225
|
+
providerUserId String? @map("provider_user_id")
|
|
226
|
+
email String? @map("email")
|
|
227
|
+
linkedAt DateTime @default(now()) @map("linked_at")
|
|
228
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
229
|
+
|
|
230
|
+
@@id([userId, provider])
|
|
231
|
+
@@index([email])
|
|
232
|
+
@@map("user_accounts")
|
|
233
|
+
}
|
|
243
234
|
|
|
244
|
-
|
|
245
|
-
|
|
235
|
+
// 3. Connected Third-Party API Resources (AuthZ)
|
|
236
|
+
model ConnectedResource {
|
|
237
|
+
userId String @map("user_id")
|
|
238
|
+
provider String @map("provider")
|
|
239
|
+
accessToken String @map("access_token")
|
|
240
|
+
refreshToken String? @map("refresh_token")
|
|
241
|
+
idToken String? @map("id_token")
|
|
242
|
+
scopes String? @map("scopes")
|
|
243
|
+
expiresAt DateTime? @map("expires_at")
|
|
244
|
+
connectedAt DateTime @default(now()) @map("connected_at")
|
|
245
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
246
|
+
|
|
247
|
+
@@id([userId, provider])
|
|
248
|
+
@@map("user_resources")
|
|
246
249
|
}
|
|
247
|
-
```
|
|
248
250
|
|
|
249
|
-
|
|
251
|
+
// 4. PKCE & OAuth Flow State
|
|
252
|
+
model OAuthState {
|
|
253
|
+
state String @id @map("state")
|
|
254
|
+
provider String? @map("provider")
|
|
255
|
+
redirectUri String? @map("redirect_uri")
|
|
256
|
+
codeVerifier String? @map("code_verifier")
|
|
257
|
+
codeChallenge String? @map("code_challenge")
|
|
258
|
+
expiresAt DateTime @map("expires_at")
|
|
259
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
260
|
+
|
|
261
|
+
@@index([expiresAt])
|
|
262
|
+
@@map("oauth_states")
|
|
263
|
+
}
|
|
264
|
+
```
|
|
250
265
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
266
|
+
Push the schema to your database:
|
|
267
|
+
```bash
|
|
268
|
+
npx prisma db push
|
|
269
|
+
```
|
|
255
270
|
|
|
256
271
|
---
|
|
257
272
|
|
|
258
|
-
|
|
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:
|
|
273
|
+
### Step 2: Initialize Lixa & Storage with 2-Minute Cache
|
|
265
274
|
|
|
266
|
-
|
|
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
|
|
275
|
+
Create `src/config/lixa.ts`:
|
|
281
276
|
|
|
282
277
|
```typescript
|
|
283
|
-
import
|
|
284
|
-
import {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
278
|
+
import { Lixa, CompositeIdentityProvider, AccountLinkingStrategy } from "@vunexa/lixa";
|
|
279
|
+
import { CognitoIdentityProvider } from "@vunexa/lixa-extensions/identity";
|
|
280
|
+
import { GoogleProvider, GithubProvider } from "@vunexa/lixa-extensions/providers";
|
|
281
|
+
import { createPrismaAdapter } from "@vunexa/lixa-extensions/storage/prisma";
|
|
282
|
+
import { PrismaClient } from "@prisma/client";
|
|
320
283
|
|
|
321
|
-
|
|
322
|
-
const stmt = this.db.prepare(`DELETE FROM sessions WHERE id = ?`);
|
|
323
|
-
stmt.run(sessionId);
|
|
324
|
-
}
|
|
284
|
+
const prisma = new PrismaClient();
|
|
325
285
|
|
|
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
286
|
export const lixa = new Lixa({
|
|
336
|
-
|
|
337
|
-
|
|
287
|
+
// 1. Unified storage with automatic 2-minute (120s) in-memory cache
|
|
288
|
+
storage: createPrismaAdapter(prisma, {
|
|
289
|
+
enableCache: true,
|
|
290
|
+
cacheTtlSeconds: 120, // Cache session validations locally
|
|
291
|
+
}),
|
|
292
|
+
|
|
293
|
+
// 2. Multi-SSO account linking strategy
|
|
294
|
+
accountLinking: {
|
|
295
|
+
mode: AccountLinkingStrategy.AUTO_LINK_BY_VERIFIED_EMAIL,
|
|
296
|
+
requireVerifiedEmail: true,
|
|
338
297
|
},
|
|
339
|
-
providers: { /* ... */ },
|
|
340
|
-
});
|
|
341
|
-
```
|
|
342
298
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
{
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
"providerUserId": "1049281048",
|
|
355
|
-
"accessToken": "ya29.a0ARW5m7...",
|
|
356
|
-
"linkedAt": 1771657200000
|
|
299
|
+
// 3. Composite Identity Provider (AWS Cognito + Social SSO)
|
|
300
|
+
identityProvider: new CompositeIdentityProvider({
|
|
301
|
+
defaultProvider: "cognito",
|
|
302
|
+
providers: {
|
|
303
|
+
cognito: new CognitoIdentityProvider({
|
|
304
|
+
userPoolId: process.env.COGNITO_USER_POOL_ID!,
|
|
305
|
+
clientId: process.env.COGNITO_CLIENT_ID!,
|
|
306
|
+
clientSecret: process.env.COGNITO_CLIENT_SECRET, // optional
|
|
307
|
+
region: process.env.AWS_REGION || "us-east-1",
|
|
308
|
+
domain: process.env.COGNITO_DOMAIN, // e.g. "my-app.auth.us-east-1.amazoncognito.com"
|
|
309
|
+
}),
|
|
357
310
|
},
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
311
|
+
oauthProviders: {
|
|
312
|
+
google: {
|
|
313
|
+
provider: new GoogleProvider(),
|
|
314
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
315
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
316
|
+
redirectUri: "http://localhost:3000/api/v1/auth/google/callback",
|
|
317
|
+
scopes: {
|
|
318
|
+
auth: ["openid", "email", "profile"],
|
|
319
|
+
resource: ["https://www.googleapis.com/auth/drive.readonly"],
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
github: {
|
|
323
|
+
provider: new GithubProvider(),
|
|
324
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
325
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
326
|
+
redirectUri: "http://localhost:3000/api/v1/auth/github/callback",
|
|
327
|
+
scopes: {
|
|
328
|
+
auth: ["read:user", "user:email"],
|
|
329
|
+
resource: ["repo", "read:org"],
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
367
335
|
```
|
|
368
336
|
|
|
369
337
|
---
|
|
370
338
|
|
|
371
|
-
###
|
|
372
|
-
|
|
373
|
-
For serverless and distributed AWS deployments using DynamoDB:
|
|
339
|
+
### Step 3: Setup Express Server & Routes
|
|
374
340
|
|
|
375
|
-
|
|
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
|
|
341
|
+
Create `src/server.ts`:
|
|
383
342
|
|
|
384
343
|
```typescript
|
|
385
|
-
import
|
|
386
|
-
import
|
|
387
|
-
import
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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: { /* ... */ },
|
|
344
|
+
import express from "express";
|
|
345
|
+
import cookieParser from "cookie-parser";
|
|
346
|
+
import cors from "cors";
|
|
347
|
+
import { createAuthRouter, createRequireAuthMiddleware } from "@vunexa/lixa-extensions/http/express";
|
|
348
|
+
import { lixa } from "./config/lixa";
|
|
349
|
+
|
|
350
|
+
const app = express();
|
|
351
|
+
app.use(express.json());
|
|
352
|
+
app.use(cookieParser(process.env.COOKIE_SECRET || "super-secret-key"));
|
|
353
|
+
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
|
|
354
|
+
|
|
355
|
+
// Auth guard middleware
|
|
356
|
+
const requireAuth = createRequireAuthMiddleware(lixa);
|
|
357
|
+
|
|
358
|
+
// Mount Lixa authentication routes:
|
|
359
|
+
// - GET /api/v1/auth/login?provider=cognito
|
|
360
|
+
// - GET /api/v1/auth/callback
|
|
361
|
+
// - POST /api/v1/auth/logout
|
|
362
|
+
// - GET /api/v1/auth/me
|
|
363
|
+
const authRouter = express.Router();
|
|
364
|
+
createAuthRouter(lixa, {
|
|
365
|
+
router: authRouter,
|
|
366
|
+
requireAuth,
|
|
367
|
+
successRedirectUrl: "http://localhost:5173/dashboard",
|
|
368
|
+
errorRedirectUrl: "http://localhost:5173/login?error=auth_failed",
|
|
463
369
|
});
|
|
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
|
-
}
|
|
370
|
+
app.use("/api/v1/auth", authRouter);
|
|
495
371
|
```
|
|
496
372
|
|
|
497
373
|
---
|
|
498
374
|
|
|
499
|
-
|
|
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
|
|
375
|
+
### Step 4: Protect Routes & Use Tokens
|
|
504
376
|
|
|
505
377
|
```typescript
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
378
|
+
// Protected route example
|
|
379
|
+
app.get("/api/v1/profile", requireAuth, async (req: any, res) => {
|
|
380
|
+
// req.session is populated automatically
|
|
381
|
+
const { userId, email, provider, accessToken, idToken } = req.session;
|
|
382
|
+
|
|
383
|
+
res.json({
|
|
384
|
+
message: "Authenticated successfully!",
|
|
385
|
+
user: {
|
|
386
|
+
userId,
|
|
387
|
+
email,
|
|
388
|
+
provider,
|
|
389
|
+
},
|
|
390
|
+
tokens: {
|
|
391
|
+
accessTokenPreview: accessToken ? `${accessToken.substring(0, 15)}...` : null,
|
|
392
|
+
idTokenPreview: idToken ? `${idToken.substring(0, 15)}...` : null,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
});
|
|
517
396
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
provider TEXT NOT NULL,
|
|
522
|
-
data TEXT NOT NULL,
|
|
523
|
-
updated_at INTEGER NOT NULL,
|
|
524
|
-
PRIMARY KEY (user_id, provider)
|
|
525
|
-
);
|
|
397
|
+
app.listen(3000, () => {
|
|
398
|
+
console.log("Server running at http://localhost:3000");
|
|
399
|
+
});
|
|
526
400
|
```
|
|
527
401
|
|
|
528
|
-
|
|
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
|
-
}
|
|
402
|
+
---
|
|
559
403
|
|
|
560
|
-
|
|
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
|
-
}
|
|
404
|
+
## 🔌 Connecting External Resources (AuthZ)
|
|
565
405
|
|
|
566
|
-
|
|
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
|
-
}
|
|
406
|
+
Unlike login authentication, **Resource Connections** allow your application to interact with external APIs (like GitHub repositories or Google Drive files) on behalf of the user.
|
|
575
407
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
408
|
+
```typescript
|
|
409
|
+
// 1. Generate connection redirect URL
|
|
410
|
+
app.get("/api/v1/resources/github/connect", requireAuth, async (req: any, res) => {
|
|
411
|
+
const url = await lixa.getResourceAuthUrl("github", {
|
|
412
|
+
userId: req.session.userId,
|
|
413
|
+
redirectUri: "http://localhost:3000/api/v1/resources/github/callback",
|
|
414
|
+
});
|
|
415
|
+
res.redirect(url);
|
|
416
|
+
});
|
|
581
417
|
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
providers: { /* ... */ },
|
|
418
|
+
// 2. Handle resource callback & store token
|
|
419
|
+
app.get("/api/v1/resources/github/callback", requireAuth, async (req: any, res) => {
|
|
420
|
+
const { code } = req.query;
|
|
421
|
+
await lixa.handleResourceCallback("github", code as string, req.session.userId);
|
|
422
|
+
res.redirect("http://localhost:5173/settings?connected=github");
|
|
588
423
|
});
|
|
589
|
-
```
|
|
590
424
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
425
|
+
// 3. Make third-party API calls using stored resource tokens
|
|
426
|
+
app.get("/api/v1/resources/github/repos", requireAuth, async (req: any, res) => {
|
|
427
|
+
const resource = await lixa.getResource(req.session.userId, "github");
|
|
428
|
+
if (!resource) {
|
|
429
|
+
return res.status(404).json({ error: "GitHub account not connected" });
|
|
604
430
|
}
|
|
605
|
-
}
|
|
606
|
-
```
|
|
607
431
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
{
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
"refreshToken": "1//09abc_google_refresh_token...",
|
|
616
|
-
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
|
617
|
-
"expiresAt": 1771660800000,
|
|
618
|
-
"connectedAt": 1771657400000
|
|
619
|
-
}
|
|
620
|
-
}
|
|
432
|
+
// Call GitHub API with resource.accessToken
|
|
433
|
+
const response = await fetch("https://api.github.com/user/repos", {
|
|
434
|
+
headers: { Authorization: `Bearer ${resource.accessToken}` },
|
|
435
|
+
});
|
|
436
|
+
const repos = await response.json();
|
|
437
|
+
res.json({ repos });
|
|
438
|
+
});
|
|
621
439
|
```
|
|
622
440
|
|
|
623
441
|
---
|
|
624
442
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
For serverless AWS deployments storing user API credentials in DynamoDB:
|
|
628
|
-
|
|
629
|
-
#### Table Configuration
|
|
443
|
+
## ⚡ In-Memory 2-Minute Caching (`withLocalCache`)
|
|
630
444
|
|
|
631
|
-
|
|
632
|
-
- **Partition Key (PK)**: `userId` (String)
|
|
633
|
-
- **Sort Key (SK)**: `provider` (String)
|
|
445
|
+
Under high traffic, hitting your database on every single API request to validate a session creates heavy I/O bottlenecks.
|
|
634
446
|
|
|
635
|
-
|
|
447
|
+
Lixa includes a high-performance **2-Minute In-Memory TTL Cache**:
|
|
636
448
|
|
|
637
449
|
```typescript
|
|
638
|
-
import {
|
|
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
|
-
}
|
|
450
|
+
import { withLocalCache } from "@vunexa/lixa-extensions/storage";
|
|
664
451
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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: { /* ... */ },
|
|
452
|
+
// Automatically applied when using createPrismaAdapter
|
|
453
|
+
const cachedStorage = withLocalCache(baseAdapter, {
|
|
454
|
+
ttlSeconds: 120, // 2 minutes (default)
|
|
716
455
|
});
|
|
717
456
|
```
|
|
718
457
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
```
|
|
458
|
+
### Cache Features & Invalidation Guarantees:
|
|
459
|
+
- **Zero Database I/O**: Session lookups within 120 seconds return in sub-millisecond memory time.
|
|
460
|
+
- **Auto-Invalidation on Logout**: Calling `deleteSession(sessionId)` immediately clears both the database record and memory cache.
|
|
461
|
+
- **Auto-Invalidation on Updates**: Saving or updating accounts/credentials purges stale cached keys automatically.
|
|
462
|
+
- **Safe Isolation**: OAuth states (single-use CSRF tokens) bypass the cache and validate directly against persistent storage.
|
|
749
463
|
|
|
750
464
|
---
|
|
751
465
|
|
|
752
|
-
|
|
466
|
+
## 🌐 Supported Identity Providers
|
|
753
467
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
headers: { Authorization: `Bearer ${githubResource.accessToken}` },
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
```
|
|
468
|
+
| Provider | Type | AuthN (SSO) | AuthZ (Resources) | Package |
|
|
469
|
+
| :--- | :--- | :---: | :---: | :--- |
|
|
470
|
+
| **AWS Cognito** | Enterprise IdP | ✅ | — | `@vunexa/lixa-extensions/identity` |
|
|
471
|
+
| **Google** | Social / OIDC | ✅ | ✅ (Drive, Gmail) | `@vunexa/lixa-extensions/providers` |
|
|
472
|
+
| **GitHub** | OAuth 2.0 | ✅ | ✅ (Repos, Orgs) | `@vunexa/lixa-extensions/providers` |
|
|
473
|
+
| **Microsoft** | Azure AD / OIDC | ✅ | ✅ (Graph API) | `@vunexa/lixa-extensions/providers` |
|
|
474
|
+
| **Auth0** | Enterprise IdP | ✅ | — | `@vunexa/lixa-extensions/identity` |
|
|
475
|
+
| **Okta** | Enterprise IdP | ✅ | — | `@vunexa/lixa-extensions/identity` |
|
|
476
|
+
| **Self-Managed** | SQLite / Postgres | ✅ | — | `@vunexa/lixa` (Built-in) |
|
|
767
477
|
|
|
768
478
|
---
|
|
769
479
|
|
|
770
|
-
##
|
|
771
|
-
|
|
772
|
-
Lixa provides utilities to extract user information from OAuth tokens:
|
|
480
|
+
## 📚 TypeScript API Reference
|
|
773
481
|
|
|
774
|
-
|
|
775
|
-
|
|
482
|
+
### `new Lixa(options)`
|
|
483
|
+
- `storage`: Storage adapter created via `createPrismaAdapter` or custom implementation.
|
|
484
|
+
- `identityProvider`: `CompositeIdentityProvider` or standalone IdP instance.
|
|
485
|
+
- `accountLinking`: Configuration for cross-provider email matching (`AccountLinkingStrategy.AUTO_LINK_BY_VERIFIED_EMAIL`).
|
|
486
|
+
- `sessionHandler`: Custom session serialization logic (optional).
|
|
776
487
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
488
|
+
### Methods
|
|
489
|
+
- `lixa.getAuthUrl(provider, options)`: Returns authorization URL with PKCE verifier stored in state.
|
|
490
|
+
- `lixa.handleCallback(provider, code, state)`: Validates state, exchanges code for tokens, creates session, and links account.
|
|
491
|
+
- `lixa.getSession(sessionId)`: Retrieves active session from memory cache or database.
|
|
492
|
+
- `lixa.deleteSession(sessionId)`: Deletes session and purges local cache.
|
|
493
|
+
- `lixa.getResourceAuthUrl(provider, options)`: Returns resource authorization URL.
|
|
494
|
+
- `lixa.handleResourceCallback(provider, code, userId)`: Stores connected resource tokens under `userId`.
|
|
495
|
+
- `lixa.getResource(userId, provider)`: Retrieves active resource token for third-party API requests.
|
|
780
496
|
|
|
781
497
|
---
|
|
782
498
|
|
|
783
|
-
## License
|
|
499
|
+
## 🤝 Contributing & License
|
|
500
|
+
|
|
501
|
+
Contributions are welcome! Please open an issue or pull request on GitHub.
|
|
784
502
|
|
|
785
|
-
|
|
503
|
+
Distributed under the **MIT License**. See `LICENSE` for details.
|