@vunexa/lixa 0.1.6-alpha.13 → 0.1.6-alpha.14
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 +101 -687
- package/dist/credentials/types.d.ts +6 -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 +19 -0
- package/dist/dao/types.d.ts.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/export-types/index.d.ts +556 -41
- package/dist/identity/index.d.ts +3 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/self-managed.d.ts +52 -0
- package/dist/identity/self-managed.d.ts.map +1 -0
- package/dist/identity/types.d.ts +224 -0
- package/dist/identity/types.d.ts.map +1 -0
- package/dist/index.cjs +658 -213
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +563 -65
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +654 -213
- package/dist/index.js.map +1 -1
- package/dist/lixa.d.ts +123 -28
- package/dist/lixa.d.ts.map +1 -1
- package/dist/models/session.d.ts +2 -2
- 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 +82 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -11
package/README.md
CHANGED
|
@@ -6,28 +6,43 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/@vunexa/lixa)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
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**.
|
|
9
|
+
A flexible, provider-agnostic OAuth 2.0 and OpenID Connect (OIDC) client library for backend Node.js applications.
|
|
10
|
+
`@vunexa/lixa` simplifies multi-provider authentication (e.g. Google, GitHub, Microsoft, AWS Cognito), enforces strict **AuthN/AuthZ separation**, enables **multi-SSO account linking**, and provides a dedicated post-login **Resource Connection API**.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
- **Strict AuthN / AuthZ Separation**: Primary login is automatically restricted to minimal identity scopes (`openid`, `email`, `profile`). Resource
|
|
17
|
-
- **Multi-SSO Account Linking**: Seamlessly merge
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **Automatic PKCE** (Proof Key for Code Exchange) for all OAuth flows.
|
|
24
|
-
- **TypeScript-first** with full type safety (zero `any` types).
|
|
16
|
+
- **Strict AuthN / AuthZ Separation**: Primary login is automatically restricted to minimal identity scopes (`openid`, `email`, `profile`, `read:user`, `user:email`). Resource permissions are isolated to post-login resource connection.
|
|
17
|
+
- **Multi-SSO Account Linking**: Seamlessly merge multiple authentication providers (e.g. AWS Cognito + Google + GitHub) under a single user profile based on verified email or active session context.
|
|
18
|
+
- **Persistent Account Storage (`AccountStorage`)**: Linked identity accounts remain saved in persistent database storage across session logouts and log-ins.
|
|
19
|
+
- **Post-Login Resource Connection API**: Connect third-party API providers (GitHub Repositories, Google Drive, Slack) post-authentication and manage resource access tokens bound to the user profile.
|
|
20
|
+
- **Multi-Identity Provider Architecture**: Built-in support for Self-Managed Database credentials, AWS Cognito, Auth0, and Okta via `@vunexa/lixa-extensions`.
|
|
21
|
+
- **Automatic PKCE** (Proof Key for Code Exchange) and state CSRF validation for all OAuth flows.
|
|
22
|
+
- **TypeScript-first**: Comprehensive type safety across all core APIs and HTTP adapters.
|
|
25
23
|
|
|
26
24
|
---
|
|
27
25
|
|
|
28
26
|
## Architecture Overview
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
```mermaid
|
|
29
|
+
flowchart TD
|
|
30
|
+
Client["Client App / Frontend"] -->|1. AuthN / Login Request| LixaEngine["Lixa Core Engine"]
|
|
31
|
+
|
|
32
|
+
subgraph AuthN ["Primary Authentication & Account Linking (AuthN)"]
|
|
33
|
+
LixaEngine -->|Minimal Scopes| IdP["Identity Provider / Social SSO (Cognito, Google, GitHub)"]
|
|
34
|
+
IdP -->|Authorization Code| Callback["/callback Endpoint"]
|
|
35
|
+
Callback -->|Verify Code + Link Accounts| SessionStore["Session & Account Storage (Prisma / Drizzle / DB)"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
subgraph AuthZ ["Post-Login Resource Connection (AuthZ)"]
|
|
39
|
+
LixaEngine -->|Resource Scopes (e.g. repo, drive)| ResourceProvider["External Resource APIs (GitHub API, Google Drive)"]
|
|
40
|
+
ResourceProvider -->|Resource Tokens| ResourceStore["Resource Storage (user_resources DB)"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
SessionStore -->|Session Cookie| Client
|
|
44
|
+
ResourceStore -->|Auto-Refreshed Tokens| Client
|
|
45
|
+
```
|
|
31
46
|
|
|
32
47
|
---
|
|
33
48
|
|
|
@@ -35,751 +50,150 @@ A flexible, provider-agnostic OAuth 2.0 and OpenID Connect (OIDC) client library
|
|
|
35
50
|
|
|
36
51
|
```bash
|
|
37
52
|
npm install @vunexa/lixa @vunexa/lixa-extensions
|
|
38
|
-
# or
|
|
39
|
-
yarn add @vunexa/lixa @vunexa/lixa-extensions
|
|
40
53
|
```
|
|
41
54
|
|
|
42
55
|
---
|
|
43
56
|
|
|
44
57
|
## Quick Start
|
|
45
58
|
|
|
46
|
-
### 1. Configure Lixa
|
|
59
|
+
### 1. Configure Lixa Instance
|
|
47
60
|
|
|
48
61
|
```typescript
|
|
49
62
|
import { Lixa, AccountLinkingStrategy } from "@vunexa/lixa";
|
|
50
63
|
import { GoogleProvider, GithubProvider } from "@vunexa/lixa-extensions/providers";
|
|
64
|
+
import { createPrismaAdapter } from "@vunexa/lixa-extensions/storage/prisma";
|
|
65
|
+
import { PrismaClient } from "@prisma/client";
|
|
66
|
+
|
|
67
|
+
const prisma = new PrismaClient();
|
|
51
68
|
|
|
52
69
|
export const lixa = new Lixa({
|
|
53
|
-
//
|
|
70
|
+
// Unified database storage for sessions, OAuth state, resources, and account linking
|
|
71
|
+
storage: createPrismaAdapter(prisma),
|
|
72
|
+
|
|
73
|
+
// Configure Multi-SSO Account Linking
|
|
54
74
|
accountLinking: {
|
|
55
75
|
mode: AccountLinkingStrategy.AUTO_LINK_BY_VERIFIED_EMAIL,
|
|
56
76
|
requireVerifiedEmail: true,
|
|
57
77
|
},
|
|
58
|
-
|
|
78
|
+
|
|
79
|
+
// Federated OAuth Providers
|
|
80
|
+
federatedOAuthProviders: {
|
|
59
81
|
google: {
|
|
60
82
|
provider: new GoogleProvider(),
|
|
61
83
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
62
84
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
63
|
-
redirectUri: "http://localhost:3000/
|
|
85
|
+
redirectUri: "http://localhost:3000/api/v1/oidc/google/callback",
|
|
64
86
|
scopes: ["openid", "email", "profile"],
|
|
65
87
|
},
|
|
66
88
|
github: {
|
|
67
89
|
provider: new GithubProvider(),
|
|
68
90
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
69
91
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
70
|
-
redirectUri: "http://localhost:3000/
|
|
92
|
+
redirectUri: "http://localhost:3000/api/v1/oidc/github/callback",
|
|
71
93
|
scopes: ["read:user", "user:email"],
|
|
72
94
|
},
|
|
73
95
|
},
|
|
74
96
|
});
|
|
75
97
|
```
|
|
76
98
|
|
|
77
|
-
### 2. Primary Authentication & Callback
|
|
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;
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const sessionId = await lixa.handleCallback({
|
|
93
|
-
provider,
|
|
94
|
-
code: code as string,
|
|
95
|
-
state: state as string,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
res.cookie("session_id", sessionId, { httpOnly: true, secure: true });
|
|
99
|
-
res.redirect("/profile");
|
|
100
|
-
} catch (error) {
|
|
101
|
-
res.status(401).send("Authentication failed");
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
```
|
|
105
|
-
|
|
106
99
|
---
|
|
107
100
|
|
|
108
101
|
## Multi-SSO Account Linking
|
|
109
102
|
|
|
110
|
-
Lixa
|
|
111
|
-
|
|
112
|
-
### Account Linking Sequence Diagram
|
|
103
|
+
Lixa allows users to sign in with different identity providers (e.g. Username/Password, AWS Cognito, Google, GitHub) and links them to the same underlying user account.
|
|
113
104
|
|
|
114
|
-
|
|
105
|
+
### Account Linking Sequence
|
|
115
106
|
|
|
116
|
-
|
|
107
|
+
```mermaid
|
|
108
|
+
sequenceDiagram
|
|
109
|
+
autonumber
|
|
110
|
+
actor User
|
|
111
|
+
participant Frontend
|
|
112
|
+
participant Express as Backend Express App
|
|
113
|
+
participant Lixa as Lixa Core Engine
|
|
114
|
+
participant GitHub as GitHub OAuth
|
|
115
|
+
participant DB as Prisma / Drizzle DB
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
mode: AccountLinkingStrategy.AUTO_LINK_BY_VERIFIED_EMAIL,
|
|
124
|
-
requireVerifiedEmail: true,
|
|
125
|
-
}
|
|
117
|
+
User->>Frontend: Click "Link GitHub"
|
|
118
|
+
Frontend->>Express: GET /login?provider=github
|
|
119
|
+
Express->>Lixa: getAuthUrl("github")
|
|
120
|
+
Lixa-->>Express: Returns Auth URL + State
|
|
121
|
+
Express-->>User: 302 Redirect to GitHub
|
|
126
122
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
User->>GitHub: Authenticate & Grant Identity Scope
|
|
124
|
+
GitHub-->>Express: GET /callback?code=123&state=abc (Cookie: session_id)
|
|
125
|
+
Express->>Lixa: handleCallback({ provider: "github", code, state, sessionId })
|
|
126
|
+
Lixa->>GitHub: Exchange Code for Access Token
|
|
127
|
+
GitHub-->>Lixa: Return Access Token + Profile (email)
|
|
128
|
+
Lixa->>DB: Save Account Link (accounts.github) to user_resources DB
|
|
129
|
+
Lixa-->>Express: Return Updated Session ID
|
|
130
|
+
Express-->>Frontend: Redirect to /dashboard with updated session cookie
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
###
|
|
133
|
+
### Account Linking Usage
|
|
134
134
|
|
|
135
135
|
```typescript
|
|
136
136
|
// Explicitly link a new provider while authenticated
|
|
137
|
-
await lixa.
|
|
138
|
-
sessionId: req.cookies.session_id,
|
|
137
|
+
const sessionId = await lixa.handleCallback({
|
|
139
138
|
provider: "github",
|
|
140
139
|
code: req.query.code as string,
|
|
141
140
|
state: req.query.state as string,
|
|
141
|
+
sessionId: activeSessionId, // Links GitHub directly to the active session
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
// Unlink a provider account
|
|
145
|
-
await lixa.unlinkAccount(
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
---
|
|
149
|
-
|
|
150
|
-
## Post-Login Resource Connection API (AuthZ)
|
|
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
|
|
155
|
-
|
|
156
|
-

|
|
157
|
-
|
|
158
|
-
### Resource Connection Usage Example
|
|
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");
|
|
209
|
-
|
|
210
|
-
// 5. Disconnect Resource Provider
|
|
211
|
-
app.delete("/connect/github", async (req, res) => {
|
|
212
|
-
await lixa.disconnectResource(req.cookies.session_id, "github");
|
|
213
|
-
res.json({ success: true });
|
|
214
|
-
});
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
---
|
|
218
|
-
|
|
219
|
-
## Session Interface Structure
|
|
220
|
-
|
|
221
|
-
```typescript
|
|
222
|
-
export interface Session<TRaw = OAuthTokenResponse> {
|
|
223
|
-
/** Unique session ID generated by Lixa */
|
|
224
|
-
id?: string;
|
|
225
|
-
|
|
226
|
-
/** Unified user ID across linked accounts */
|
|
227
|
-
userId?: string;
|
|
228
|
-
|
|
229
|
-
/** Primary user email */
|
|
230
|
-
email?: string;
|
|
231
|
-
|
|
232
|
-
/** Linked SSO provider accounts (AuthN) - Single Source of Truth */
|
|
233
|
-
accounts?: Record<string, LinkedAccount>;
|
|
234
|
-
|
|
235
|
-
/** Connected third-party resource provider tokens (AuthZ) - Single Source of Truth */
|
|
236
|
-
resources?: Record<string, ConnectedResource>;
|
|
237
|
-
|
|
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;
|
|
246
|
-
}
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
### `LinkedAccount` vs `ConnectedResource`
|
|
250
|
-
|
|
251
|
-
| Concept | Purpose | Scopes Allowed | Stored Location |
|
|
252
|
-
| :--- | :--- | :--- | :--- |
|
|
253
|
-
| **`LinkedAccount`** | Identity verification & multi-SSO merging (**AuthN**) | Minimal identity scopes (`openid`, `email`, `read:user`) | `session.accounts[provider]` |
|
|
254
|
-
| **`ConnectedResource`** | External API resource access (**AuthZ**) | Resource permissions (`repo`, `drive.readonly`) | `session.resources[provider]` |
|
|
255
|
-
|
|
256
|
-
---
|
|
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
|
-
}
|
|
145
|
+
await lixa.unlinkAccount(activeSessionId, "github");
|
|
495
146
|
```
|
|
496
147
|
|
|
497
148
|
---
|
|
498
149
|
|
|
499
|
-
##
|
|
150
|
+
## Post-Login Resource Connection (AuthZ API)
|
|
500
151
|
|
|
501
|
-
|
|
152
|
+
Primary authentication is strictly limited to identity scopes. To request third-party API permissions (such as GitHub Repositories or Google Drive access), use Lixa's post-login **Resource Connection API**.
|
|
502
153
|
|
|
503
|
-
###
|
|
154
|
+
### Resource Connection Sequence
|
|
504
155
|
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
156
|
+
```mermaid
|
|
157
|
+
sequenceDiagram
|
|
158
|
+
autonumber
|
|
159
|
+
actor User
|
|
160
|
+
participant Frontend
|
|
161
|
+
participant Express as Backend Express App
|
|
162
|
+
participant Lixa as Lixa Core Engine
|
|
163
|
+
participant ResourceAPI as GitHub / Google Drive API
|
|
513
164
|
|
|
514
|
-
|
|
165
|
+
User->>Frontend: Click "Connect GitHub Repos"
|
|
166
|
+
Frontend->>Express: GET /connect-resource/github
|
|
167
|
+
Express->>Lixa: getResourceAuthUrl({ sessionId, provider: "github", scopes: ["repo"] })
|
|
168
|
+
Lixa-->>Express: Returns Consent URL + Resource State
|
|
169
|
+
Express-->>User: 302 Redirect to Provider Consent Page
|
|
515
170
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
updated_at INTEGER NOT NULL,
|
|
524
|
-
PRIMARY KEY (user_id, provider)
|
|
525
|
-
);
|
|
171
|
+
User->>ResourceAPI: Grant Resource Permission ("repo")
|
|
172
|
+
ResourceAPI-->>Express: GET /callback?code=xyz (Cookie: session_id, lixa_resource_flow=true)
|
|
173
|
+
Express->>Lixa: handleResourceCallback({ sessionId, provider: "github", code })
|
|
174
|
+
Lixa->>ResourceAPI: Exchange Code for Resource Access & Refresh Tokens
|
|
175
|
+
Lixa->>DB: Save Resource Tokens to user_resources Table
|
|
176
|
+
Lixa-->>Express: Return Updated Session
|
|
177
|
+
Express-->>Frontend: 200 OK / Redirect to Dashboard
|
|
526
178
|
```
|
|
527
179
|
|
|
528
|
-
|
|
180
|
+
### Querying Connected Resource Access Tokens
|
|
529
181
|
|
|
530
182
|
```typescript
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
}
|
|
183
|
+
// Query connected resource (automatically handles token refreshes)
|
|
184
|
+
const resource = await lixa.getConnectedResource(sessionId, "github");
|
|
581
185
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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}` },
|
|
186
|
+
if (resource) {
|
|
187
|
+
// Use access token to call external API
|
|
188
|
+
const reposResponse = await fetch("https://api.github.com/user/repos", {
|
|
189
|
+
headers: { Authorization: `Bearer ${resource.accessToken}` },
|
|
764
190
|
});
|
|
191
|
+
const repos = await reposResponse.json();
|
|
765
192
|
}
|
|
766
193
|
```
|
|
767
194
|
|
|
768
195
|
---
|
|
769
196
|
|
|
770
|
-
## User Info Utilities
|
|
771
|
-
|
|
772
|
-
Lixa provides utilities to extract user information from OAuth tokens:
|
|
773
|
-
|
|
774
|
-
```typescript
|
|
775
|
-
import { extractUserInfo, type UserInfo } from "@vunexa/lixa";
|
|
776
|
-
|
|
777
|
-
const { userInfo } = await extractUserInfo(tokenData, providerMetadata);
|
|
778
|
-
console.log(userInfo.email, userInfo.name);
|
|
779
|
-
```
|
|
780
|
-
|
|
781
|
-
---
|
|
782
|
-
|
|
783
197
|
## License
|
|
784
198
|
|
|
785
|
-
[
|
|
199
|
+
MIT © [Vunexa](https://github.com/vunexa)
|