@parsrun/auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/dist/adapters/hono.d.ts +9 -0
- package/dist/adapters/hono.js +6 -0
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +7 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/authorization-By1Xp8Za.d.ts +213 -0
- package/dist/base-BKyR8rcE.d.ts +646 -0
- package/dist/chunk-42MGHABB.js +263 -0
- package/dist/chunk-42MGHABB.js.map +1 -0
- package/dist/chunk-7GOBAL4G.js +3 -0
- package/dist/chunk-7GOBAL4G.js.map +1 -0
- package/dist/chunk-G5I3T73A.js +152 -0
- package/dist/chunk-G5I3T73A.js.map +1 -0
- package/dist/chunk-IB4WUQDZ.js +410 -0
- package/dist/chunk-IB4WUQDZ.js.map +1 -0
- package/dist/chunk-MOG4Y6I7.js +415 -0
- package/dist/chunk-MOG4Y6I7.js.map +1 -0
- package/dist/chunk-NK4TJV2W.js +295 -0
- package/dist/chunk-NK4TJV2W.js.map +1 -0
- package/dist/chunk-RHNVRCF3.js +838 -0
- package/dist/chunk-RHNVRCF3.js.map +1 -0
- package/dist/chunk-YTCPXJR5.js +570 -0
- package/dist/chunk-YTCPXJR5.js.map +1 -0
- package/dist/cloudflare-kv-L64CZKDK.js +105 -0
- package/dist/cloudflare-kv-L64CZKDK.js.map +1 -0
- package/dist/deno-kv-F55HKKP6.js +111 -0
- package/dist/deno-kv-F55HKKP6.js.map +1 -0
- package/dist/index-C3kz9XqE.d.ts +226 -0
- package/dist/index-DOGcetyD.d.ts +1041 -0
- package/dist/index.d.ts +1579 -0
- package/dist/index.js +4294 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt-manager-CH8H0kmm.d.ts +182 -0
- package/dist/providers/index.d.ts +90 -0
- package/dist/providers/index.js +3 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/otp/index.d.ts +3 -0
- package/dist/providers/otp/index.js +4 -0
- package/dist/providers/otp/index.js.map +1 -0
- package/dist/redis-5TIS6XCA.js +121 -0
- package/dist/redis-5TIS6XCA.js.map +1 -0
- package/dist/security/index.d.ts +301 -0
- package/dist/security/index.js +5 -0
- package/dist/security/index.js.map +1 -0
- package/dist/session/index.d.ts +117 -0
- package/dist/session/index.js +4 -0
- package/dist/session/index.js.map +1 -0
- package/dist/storage/index.d.ts +97 -0
- package/dist/storage/index.js +3 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/types-DSjafxJ4.d.ts +193 -0
- package/package.json +102 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4294 @@
|
|
|
1
|
+
import './chunk-7GOBAL4G.js';
|
|
2
|
+
export { createAuthCookies, createAuthMiddleware, createAuthRoutes, createHonoAuth, createLogoutCookies, createOptionalAuthMiddleware, requireAdmin, requireAll, requireAny, requireAnyPermission, requireOwnerOrPermission, requirePermission, requireRole, requireTenant, requireTenantAccess } from './chunk-RHNVRCF3.js';
|
|
3
|
+
import { ProviderRegistry } from './chunk-G5I3T73A.js';
|
|
4
|
+
export { ProviderRegistry } from './chunk-G5I3T73A.js';
|
|
5
|
+
import { generateRandomHex, sha256Hex } from './chunk-YTCPXJR5.js';
|
|
6
|
+
export { CsrfManager, CsrfUtils, DefaultLockoutConfig, LockoutManager, RateLimitPresets, RateLimiter, base64UrlDecode, base64UrlEncode, bytesToHex, createCsrfManager, createLockoutManager, createRateLimiter, generateRandomBase64Url, generateRandomHex, hexToBytes, randomInt, sha256, sha256Hex, timingSafeEqual, timingSafeEqualBytes } from './chunk-YTCPXJR5.js';
|
|
7
|
+
export { AuthorizationGuard, Permissions, Roles, authorize, createAuthorizationGuard } from './chunk-NK4TJV2W.js';
|
|
8
|
+
import { JwtManager, SessionBlocklist } from './chunk-MOG4Y6I7.js';
|
|
9
|
+
export { JwtError, JwtManager, SessionBlocklist, TokenBlocklist, createJwtManager, createSessionBlocklist, createTokenBlocklist, extractBearerToken, parseDuration } from './chunk-MOG4Y6I7.js';
|
|
10
|
+
import { OTPProvider } from './chunk-IB4WUQDZ.js';
|
|
11
|
+
export { OTPManager, OTPProvider, createOTPManager, createOTPProvider } from './chunk-IB4WUQDZ.js';
|
|
12
|
+
import { createStorage } from './chunk-42MGHABB.js';
|
|
13
|
+
export { MemoryStorage, StorageKeys, createMemoryStorage, createStorage, createStorageSync, detectRuntime, getEnv, isBun, isCloudflare, isDeno, isEdge, isNode } from './chunk-42MGHABB.js';
|
|
14
|
+
import { eq, and, isNull, desc } from 'drizzle-orm';
|
|
15
|
+
|
|
16
|
+
// src/config.ts
|
|
17
|
+
var defaultConfig = {
|
|
18
|
+
providers: {
|
|
19
|
+
otp: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
email: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
expiresIn: 600,
|
|
24
|
+
length: 6,
|
|
25
|
+
maxAttempts: 3,
|
|
26
|
+
rateLimit: 5,
|
|
27
|
+
rateLimitWindow: 900,
|
|
28
|
+
// send function must be provided by user
|
|
29
|
+
send: async () => {
|
|
30
|
+
throw new Error("[Pars Auth] Email OTP send function not configured");
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
sms: {
|
|
34
|
+
enabled: false,
|
|
35
|
+
expiresIn: 300,
|
|
36
|
+
length: 6,
|
|
37
|
+
maxAttempts: 3,
|
|
38
|
+
rateLimit: 3,
|
|
39
|
+
rateLimitWindow: 900,
|
|
40
|
+
send: async () => {
|
|
41
|
+
throw new Error("[Pars Auth] SMS OTP send function not configured");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
password: {
|
|
46
|
+
enabled: false
|
|
47
|
+
// EXPLICITLY DISABLED BY DEFAULT
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
session: {
|
|
51
|
+
accessTokenExpiry: 900,
|
|
52
|
+
// 15 minutes
|
|
53
|
+
refreshTokenExpiry: 604800,
|
|
54
|
+
// 7 days
|
|
55
|
+
slidingWindow: true,
|
|
56
|
+
maxSessions: 5,
|
|
57
|
+
invalidateOnPasswordChange: true
|
|
58
|
+
},
|
|
59
|
+
jwt: {
|
|
60
|
+
algorithm: "HS256"
|
|
61
|
+
},
|
|
62
|
+
cookies: {
|
|
63
|
+
prefix: "pars",
|
|
64
|
+
path: "/",
|
|
65
|
+
sameSite: "lax",
|
|
66
|
+
httpOnly: true
|
|
67
|
+
},
|
|
68
|
+
security: {
|
|
69
|
+
rateLimit: {
|
|
70
|
+
enabled: true,
|
|
71
|
+
loginAttempts: 5,
|
|
72
|
+
windowSize: 900
|
|
73
|
+
},
|
|
74
|
+
lockout: {
|
|
75
|
+
enabled: true,
|
|
76
|
+
maxAttempts: 5,
|
|
77
|
+
duration: 900
|
|
78
|
+
},
|
|
79
|
+
csrf: {
|
|
80
|
+
enabled: true,
|
|
81
|
+
headerName: "x-csrf-token",
|
|
82
|
+
cookieName: "csrf"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
tenant: {
|
|
86
|
+
enabled: true,
|
|
87
|
+
strategy: "header",
|
|
88
|
+
headerName: "x-tenant-id"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
function mergeConfig(config) {
|
|
92
|
+
return {
|
|
93
|
+
secret: config.secret,
|
|
94
|
+
baseUrl: config.baseUrl ?? "",
|
|
95
|
+
storage: config.storage ?? {},
|
|
96
|
+
providers: {
|
|
97
|
+
...defaultConfig.providers,
|
|
98
|
+
...config.providers,
|
|
99
|
+
otp: {
|
|
100
|
+
...defaultConfig.providers?.otp,
|
|
101
|
+
...config.providers?.otp,
|
|
102
|
+
email: {
|
|
103
|
+
...defaultConfig.providers?.otp?.email,
|
|
104
|
+
...config.providers?.otp?.email
|
|
105
|
+
},
|
|
106
|
+
sms: {
|
|
107
|
+
...defaultConfig.providers?.otp?.sms,
|
|
108
|
+
...config.providers?.otp?.sms
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
password: {
|
|
112
|
+
...defaultConfig.providers?.password,
|
|
113
|
+
...config.providers?.password
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
session: { ...defaultConfig.session, ...config.session },
|
|
117
|
+
jwt: { ...defaultConfig.jwt, ...config.jwt },
|
|
118
|
+
cookies: { ...defaultConfig.cookies, ...config.cookies },
|
|
119
|
+
security: {
|
|
120
|
+
...defaultConfig.security,
|
|
121
|
+
...config.security,
|
|
122
|
+
rateLimit: { ...defaultConfig.security?.rateLimit, ...config.security?.rateLimit },
|
|
123
|
+
lockout: { ...defaultConfig.security?.lockout, ...config.security?.lockout },
|
|
124
|
+
csrf: { ...defaultConfig.security?.csrf, ...config.security?.csrf }
|
|
125
|
+
},
|
|
126
|
+
tenant: { ...defaultConfig.tenant, ...config.tenant },
|
|
127
|
+
adapter: config.adapter,
|
|
128
|
+
callbacks: config.callbacks ?? {}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function validateConfig(config) {
|
|
132
|
+
if (!config.secret) {
|
|
133
|
+
throw new Error("[Pars Auth] Secret is required");
|
|
134
|
+
}
|
|
135
|
+
if (config.secret.length < 32) {
|
|
136
|
+
console.warn(
|
|
137
|
+
"[Pars Auth] Secret should be at least 32 characters for security"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!config.adapter) {
|
|
141
|
+
throw new Error("[Pars Auth] Database adapter is required");
|
|
142
|
+
}
|
|
143
|
+
if (config.providers?.password?.enabled) {
|
|
144
|
+
console.warn(
|
|
145
|
+
"[Pars Auth] Password authentication is enabled. Consider using passwordless authentication (OTP, Magic Link, WebAuthn) for better security."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (config.providers?.otp?.email?.enabled !== false && !config.providers?.otp?.email?.send) {
|
|
149
|
+
console.warn(
|
|
150
|
+
"[Pars Auth] Email OTP is enabled but send function is not configured"
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/core/tenant-manager.ts
|
|
156
|
+
var TenantManager = class {
|
|
157
|
+
adapter;
|
|
158
|
+
constructor(adapter) {
|
|
159
|
+
this.adapter = adapter;
|
|
160
|
+
}
|
|
161
|
+
// ============================================
|
|
162
|
+
// Tenant Operations
|
|
163
|
+
// ============================================
|
|
164
|
+
/**
|
|
165
|
+
* Create a new tenant with owner
|
|
166
|
+
*/
|
|
167
|
+
async createTenant(input) {
|
|
168
|
+
if (!this.adapter.findTenantById) {
|
|
169
|
+
throw new Error("Tenant operations not supported by adapter");
|
|
170
|
+
}
|
|
171
|
+
const slug = input.slug ?? this.generateSlug(input.name);
|
|
172
|
+
const existingTenant = await this.adapter.findTenantBySlug?.(slug);
|
|
173
|
+
if (existingTenant) {
|
|
174
|
+
throw new Error(`Tenant with slug '${slug}' already exists`);
|
|
175
|
+
}
|
|
176
|
+
throw new Error(
|
|
177
|
+
"createTenant not implemented in adapter. Please implement createTenant in your adapter or use direct database access."
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get tenant by ID
|
|
182
|
+
*/
|
|
183
|
+
async getTenantById(id) {
|
|
184
|
+
if (!this.adapter.findTenantById) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return this.adapter.findTenantById(id);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get tenant by slug
|
|
191
|
+
*/
|
|
192
|
+
async getTenantBySlug(slug) {
|
|
193
|
+
if (!this.adapter.findTenantBySlug) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return this.adapter.findTenantBySlug(slug);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Check if user is member of tenant
|
|
200
|
+
*/
|
|
201
|
+
async isMember(userId, tenantId) {
|
|
202
|
+
if (!this.adapter.findMembership) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const membership = await this.adapter.findMembership(userId, tenantId);
|
|
206
|
+
return membership !== null && membership.status === "active";
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Check if user has specific role in tenant
|
|
210
|
+
*/
|
|
211
|
+
async hasRole(userId, tenantId, role) {
|
|
212
|
+
if (!this.adapter.findMembership) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const membership = await this.adapter.findMembership(userId, tenantId);
|
|
216
|
+
return membership !== null && membership.role === role && membership.status === "active";
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Check if user is owner of tenant
|
|
220
|
+
*/
|
|
221
|
+
async isOwner(userId, tenantId) {
|
|
222
|
+
return this.hasRole(userId, tenantId, "owner");
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check if user is admin of tenant (owner or admin)
|
|
226
|
+
*/
|
|
227
|
+
async isAdmin(userId, tenantId) {
|
|
228
|
+
if (!this.adapter.findMembership) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const membership = await this.adapter.findMembership(userId, tenantId);
|
|
232
|
+
if (!membership || membership.status !== "active") {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
return membership.role === "owner" || membership.role === "admin";
|
|
236
|
+
}
|
|
237
|
+
// ============================================
|
|
238
|
+
// Membership Operations
|
|
239
|
+
// ============================================
|
|
240
|
+
/**
|
|
241
|
+
* Add a member to tenant
|
|
242
|
+
*/
|
|
243
|
+
async addMember(input) {
|
|
244
|
+
if (!this.adapter.createMembership) {
|
|
245
|
+
throw new Error("Membership operations not supported by adapter");
|
|
246
|
+
}
|
|
247
|
+
const existing = await this.adapter.findMembership?.(input.userId, input.tenantId);
|
|
248
|
+
if (existing) {
|
|
249
|
+
throw new Error("User is already a member of this tenant");
|
|
250
|
+
}
|
|
251
|
+
return this.adapter.createMembership({
|
|
252
|
+
userId: input.userId,
|
|
253
|
+
tenantId: input.tenantId,
|
|
254
|
+
role: input.role,
|
|
255
|
+
permissions: input.permissions
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Update member role/permissions
|
|
260
|
+
*/
|
|
261
|
+
async updateMember(userId, tenantId, updates) {
|
|
262
|
+
if (!this.adapter.findMembership || !this.adapter.updateMembership) {
|
|
263
|
+
throw new Error("Membership operations not supported by adapter");
|
|
264
|
+
}
|
|
265
|
+
const membership = await this.adapter.findMembership(userId, tenantId);
|
|
266
|
+
if (!membership) {
|
|
267
|
+
throw new Error("Membership not found");
|
|
268
|
+
}
|
|
269
|
+
return this.adapter.updateMembership(membership.id, updates);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Remove member from tenant
|
|
273
|
+
*/
|
|
274
|
+
async removeMember(userId, tenantId) {
|
|
275
|
+
if (!this.adapter.findMembership || !this.adapter.deleteMembership) {
|
|
276
|
+
throw new Error("Membership operations not supported by adapter");
|
|
277
|
+
}
|
|
278
|
+
const membership = await this.adapter.findMembership(userId, tenantId);
|
|
279
|
+
if (!membership) {
|
|
280
|
+
throw new Error("Membership not found");
|
|
281
|
+
}
|
|
282
|
+
if (membership.role === "owner") {
|
|
283
|
+
const allMemberships = await this.getMembersByTenant(tenantId);
|
|
284
|
+
const owners = allMemberships.filter((m) => m.role === "owner");
|
|
285
|
+
if (owners.length <= 1) {
|
|
286
|
+
throw new Error("Cannot remove the last owner of a tenant");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await this.adapter.deleteMembership(membership.id);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Get membership for user in tenant
|
|
293
|
+
*/
|
|
294
|
+
async getMembership(userId, tenantId) {
|
|
295
|
+
if (!this.adapter.findMembership) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return this.adapter.findMembership(userId, tenantId);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Get all tenants for a user
|
|
302
|
+
*/
|
|
303
|
+
async getUserTenants(userId) {
|
|
304
|
+
if (!this.adapter.findMembershipsByUserId) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const memberships = await this.adapter.findMembershipsByUserId(userId);
|
|
308
|
+
const enriched = await Promise.all(
|
|
309
|
+
memberships.map(async (membership) => {
|
|
310
|
+
const tenant = await this.adapter.findTenantById?.(membership.tenantId);
|
|
311
|
+
return {
|
|
312
|
+
...membership,
|
|
313
|
+
tenant: tenant ?? void 0
|
|
314
|
+
};
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
return enriched;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Get all members of a tenant
|
|
321
|
+
* Note: This requires iterating through users, which is not efficient
|
|
322
|
+
* Consider adding findMembershipsByTenantId to the adapter
|
|
323
|
+
*/
|
|
324
|
+
async getMembersByTenant(_tenantId) {
|
|
325
|
+
console.warn(
|
|
326
|
+
"getMembersByTenant: Consider implementing findMembershipsByTenantId in adapter"
|
|
327
|
+
);
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Transfer ownership to another member
|
|
332
|
+
*/
|
|
333
|
+
async transferOwnership(tenantId, currentOwnerId, newOwnerId) {
|
|
334
|
+
if (!this.adapter.findMembership || !this.adapter.updateMembership) {
|
|
335
|
+
throw new Error("Membership operations not supported by adapter");
|
|
336
|
+
}
|
|
337
|
+
const currentOwnership = await this.adapter.findMembership(currentOwnerId, tenantId);
|
|
338
|
+
if (!currentOwnership || currentOwnership.role !== "owner") {
|
|
339
|
+
throw new Error("Current user is not the owner");
|
|
340
|
+
}
|
|
341
|
+
const newOwnership = await this.adapter.findMembership(newOwnerId, tenantId);
|
|
342
|
+
if (!newOwnership) {
|
|
343
|
+
throw new Error("New owner must be an existing member");
|
|
344
|
+
}
|
|
345
|
+
await this.adapter.updateMembership(newOwnership.id, { role: "owner" });
|
|
346
|
+
await this.adapter.updateMembership(currentOwnership.id, { role: "admin" });
|
|
347
|
+
}
|
|
348
|
+
// ============================================
|
|
349
|
+
// Tenant Switching
|
|
350
|
+
// ============================================
|
|
351
|
+
/**
|
|
352
|
+
* Validate tenant switch
|
|
353
|
+
* Returns the tenant if switch is allowed
|
|
354
|
+
*/
|
|
355
|
+
async validateTenantSwitch(userId, targetTenantId) {
|
|
356
|
+
const tenant = await this.getTenantById(targetTenantId);
|
|
357
|
+
if (!tenant) {
|
|
358
|
+
throw new Error("Tenant not found");
|
|
359
|
+
}
|
|
360
|
+
if (tenant.status !== "active") {
|
|
361
|
+
throw new Error("Tenant is not active");
|
|
362
|
+
}
|
|
363
|
+
const membership = await this.getMembership(userId, targetTenantId);
|
|
364
|
+
if (!membership) {
|
|
365
|
+
throw new Error("User is not a member of this tenant");
|
|
366
|
+
}
|
|
367
|
+
if (membership.status !== "active") {
|
|
368
|
+
throw new Error("Membership is not active");
|
|
369
|
+
}
|
|
370
|
+
return { tenant, membership };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Get default tenant for user
|
|
374
|
+
* Returns the first active tenant membership
|
|
375
|
+
*/
|
|
376
|
+
async getDefaultTenant(userId) {
|
|
377
|
+
const tenants = await this.getUserTenants(userId);
|
|
378
|
+
const active = tenants.find(
|
|
379
|
+
(t) => t.status === "active" && t.tenant?.status === "active"
|
|
380
|
+
);
|
|
381
|
+
return active ?? null;
|
|
382
|
+
}
|
|
383
|
+
// ============================================
|
|
384
|
+
// Utility Methods
|
|
385
|
+
// ============================================
|
|
386
|
+
/**
|
|
387
|
+
* Generate URL-friendly slug from name
|
|
388
|
+
*/
|
|
389
|
+
generateSlug(name) {
|
|
390
|
+
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Generate unique tenant slug
|
|
394
|
+
*/
|
|
395
|
+
async generateUniqueSlug(name) {
|
|
396
|
+
const baseSlug = this.generateSlug(name);
|
|
397
|
+
let slug = baseSlug;
|
|
398
|
+
let counter = 1;
|
|
399
|
+
while (await this.adapter.findTenantBySlug?.(slug)) {
|
|
400
|
+
slug = `${baseSlug}-${counter}`;
|
|
401
|
+
counter++;
|
|
402
|
+
}
|
|
403
|
+
return slug;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
function createTenantManager(adapter) {
|
|
407
|
+
return new TenantManager(adapter);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/core/tenant-resolver.ts
|
|
411
|
+
var TenantResolver = class {
|
|
412
|
+
config;
|
|
413
|
+
constructor(config) {
|
|
414
|
+
this.config = {
|
|
415
|
+
headerName: "x-tenant-id",
|
|
416
|
+
pathPrefix: "/t/",
|
|
417
|
+
queryParam: "tenant",
|
|
418
|
+
required: false,
|
|
419
|
+
...config
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Resolve tenant from request
|
|
424
|
+
*/
|
|
425
|
+
async resolve(request) {
|
|
426
|
+
const { strategy } = this.config;
|
|
427
|
+
let result = {
|
|
428
|
+
tenantId: null,
|
|
429
|
+
resolvedFrom: null
|
|
430
|
+
};
|
|
431
|
+
switch (strategy) {
|
|
432
|
+
case "subdomain":
|
|
433
|
+
result = this.resolveFromSubdomain(request);
|
|
434
|
+
break;
|
|
435
|
+
case "header":
|
|
436
|
+
result = this.resolveFromHeader(request);
|
|
437
|
+
break;
|
|
438
|
+
case "path":
|
|
439
|
+
result = this.resolveFromPath(request);
|
|
440
|
+
break;
|
|
441
|
+
case "query":
|
|
442
|
+
result = this.resolveFromQuery(request);
|
|
443
|
+
break;
|
|
444
|
+
case "custom":
|
|
445
|
+
result = await this.resolveFromCustom(request);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
if (!result.tenantId && this.config.fallbackTenantId) {
|
|
449
|
+
result = {
|
|
450
|
+
tenantId: this.config.fallbackTenantId,
|
|
451
|
+
resolvedFrom: "fallback"
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Resolve tenant from subdomain
|
|
458
|
+
* e.g., acme.example.com -> 'acme'
|
|
459
|
+
*/
|
|
460
|
+
resolveFromSubdomain(request) {
|
|
461
|
+
const url = new URL(request.url);
|
|
462
|
+
const host = url.hostname;
|
|
463
|
+
const parts = host.split(".");
|
|
464
|
+
if (parts.length >= 3 || parts.length === 2 && parts[1] === "localhost") {
|
|
465
|
+
const subdomain = parts[0];
|
|
466
|
+
const skipSubdomains = ["www", "api", "app", "admin", "mail", "ftp"];
|
|
467
|
+
if (!skipSubdomains.includes(subdomain.toLowerCase())) {
|
|
468
|
+
return {
|
|
469
|
+
tenantId: subdomain,
|
|
470
|
+
resolvedFrom: "subdomain",
|
|
471
|
+
originalValue: subdomain
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { tenantId: null, resolvedFrom: null };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Resolve tenant from header
|
|
479
|
+
* e.g., X-Tenant-ID: acme
|
|
480
|
+
*/
|
|
481
|
+
resolveFromHeader(request) {
|
|
482
|
+
const headerValue = request.headers.get(this.config.headerName);
|
|
483
|
+
if (headerValue) {
|
|
484
|
+
return {
|
|
485
|
+
tenantId: headerValue,
|
|
486
|
+
resolvedFrom: "header",
|
|
487
|
+
originalValue: headerValue
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return { tenantId: null, resolvedFrom: null };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Resolve tenant from URL path
|
|
494
|
+
* e.g., /t/acme/api/users -> 'acme'
|
|
495
|
+
*/
|
|
496
|
+
resolveFromPath(request) {
|
|
497
|
+
const url = new URL(request.url);
|
|
498
|
+
const pathname = url.pathname;
|
|
499
|
+
const prefix = this.config.pathPrefix;
|
|
500
|
+
if (pathname.startsWith(prefix)) {
|
|
501
|
+
const rest = pathname.slice(prefix.length);
|
|
502
|
+
const slashIndex = rest.indexOf("/");
|
|
503
|
+
const tenantSlug = slashIndex > 0 ? rest.slice(0, slashIndex) : rest;
|
|
504
|
+
if (tenantSlug) {
|
|
505
|
+
return {
|
|
506
|
+
tenantId: tenantSlug,
|
|
507
|
+
resolvedFrom: "path",
|
|
508
|
+
originalValue: tenantSlug
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { tenantId: null, resolvedFrom: null };
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Resolve tenant from query parameter
|
|
516
|
+
* e.g., /api/users?tenant=acme -> 'acme'
|
|
517
|
+
*/
|
|
518
|
+
resolveFromQuery(request) {
|
|
519
|
+
const url = new URL(request.url);
|
|
520
|
+
const tenantId = url.searchParams.get(this.config.queryParam);
|
|
521
|
+
if (tenantId) {
|
|
522
|
+
return {
|
|
523
|
+
tenantId,
|
|
524
|
+
resolvedFrom: "query",
|
|
525
|
+
originalValue: tenantId
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return { tenantId: null, resolvedFrom: null };
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Resolve tenant using custom resolver
|
|
532
|
+
*/
|
|
533
|
+
async resolveFromCustom(request) {
|
|
534
|
+
if (!this.config.resolver) {
|
|
535
|
+
return { tenantId: null, resolvedFrom: null };
|
|
536
|
+
}
|
|
537
|
+
const tenantId = await this.config.resolver(request);
|
|
538
|
+
if (tenantId) {
|
|
539
|
+
return {
|
|
540
|
+
tenantId,
|
|
541
|
+
resolvedFrom: "custom",
|
|
542
|
+
originalValue: tenantId
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return { tenantId: null, resolvedFrom: null };
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Check if tenant is required but not found
|
|
549
|
+
*/
|
|
550
|
+
isRequired() {
|
|
551
|
+
return this.config.required;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Get the path without tenant prefix (for path strategy)
|
|
555
|
+
*/
|
|
556
|
+
stripTenantFromPath(pathname) {
|
|
557
|
+
if (this.config.strategy !== "path") {
|
|
558
|
+
return pathname;
|
|
559
|
+
}
|
|
560
|
+
const prefix = this.config.pathPrefix;
|
|
561
|
+
if (pathname.startsWith(prefix)) {
|
|
562
|
+
const rest = pathname.slice(prefix.length);
|
|
563
|
+
const slashIndex = rest.indexOf("/");
|
|
564
|
+
if (slashIndex > 0) {
|
|
565
|
+
return rest.slice(slashIndex);
|
|
566
|
+
}
|
|
567
|
+
return "/";
|
|
568
|
+
}
|
|
569
|
+
return pathname;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
function createTenantResolver(config) {
|
|
573
|
+
return new TenantResolver(config);
|
|
574
|
+
}
|
|
575
|
+
var MultiStrategyTenantResolver = class {
|
|
576
|
+
resolvers;
|
|
577
|
+
fallbackTenantId;
|
|
578
|
+
constructor(strategies, options) {
|
|
579
|
+
this.resolvers = strategies.map((s) => new TenantResolver(s));
|
|
580
|
+
this.fallbackTenantId = options?.fallbackTenantId;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Resolve tenant trying each strategy in order
|
|
584
|
+
*/
|
|
585
|
+
async resolve(request) {
|
|
586
|
+
for (const resolver of this.resolvers) {
|
|
587
|
+
const result = await resolver.resolve(request);
|
|
588
|
+
if (result.tenantId) {
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (this.fallbackTenantId) {
|
|
593
|
+
return {
|
|
594
|
+
tenantId: this.fallbackTenantId,
|
|
595
|
+
resolvedFrom: "fallback"
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
return { tenantId: null, resolvedFrom: null };
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
function createMultiStrategyResolver(strategies, options) {
|
|
602
|
+
return new MultiStrategyTenantResolver(strategies, options);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/core/invitation.ts
|
|
606
|
+
var InvitationService = class {
|
|
607
|
+
storage;
|
|
608
|
+
adapter;
|
|
609
|
+
config;
|
|
610
|
+
constructor(storage, adapter, config) {
|
|
611
|
+
this.storage = storage;
|
|
612
|
+
this.adapter = adapter;
|
|
613
|
+
this.config = {
|
|
614
|
+
callbackPath: "/auth/invitation",
|
|
615
|
+
expiresIn: 604800,
|
|
616
|
+
// 7 days
|
|
617
|
+
tokenLength: 32,
|
|
618
|
+
...config
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Send an invitation to join a tenant
|
|
623
|
+
*/
|
|
624
|
+
async sendInvitation(input) {
|
|
625
|
+
const normalizedEmail = input.email.toLowerCase().trim();
|
|
626
|
+
const existingUser = await this.adapter.findUserByEmail(normalizedEmail);
|
|
627
|
+
if (existingUser) {
|
|
628
|
+
const membership = await this.adapter.findMembership?.(existingUser.id, input.tenantId);
|
|
629
|
+
if (membership && membership.status === "active") {
|
|
630
|
+
return { success: false, error: "User is already a member of this tenant" };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const existingInvitation = await this.getInvitationByEmail(normalizedEmail, input.tenantId);
|
|
634
|
+
if (existingInvitation && existingInvitation.status === "pending") {
|
|
635
|
+
await this.cancelInvitation(existingInvitation.id);
|
|
636
|
+
}
|
|
637
|
+
const token = await generateRandomHex(this.config.tokenLength);
|
|
638
|
+
const tokenHash = await sha256Hex(token);
|
|
639
|
+
const id = await generateRandomHex(16);
|
|
640
|
+
const expiresAt = new Date(Date.now() + this.config.expiresIn * 1e3);
|
|
641
|
+
const invitation = {
|
|
642
|
+
id,
|
|
643
|
+
email: normalizedEmail,
|
|
644
|
+
tenantId: input.tenantId,
|
|
645
|
+
role: input.role,
|
|
646
|
+
permissions: input.permissions,
|
|
647
|
+
invitedBy: input.invitedBy,
|
|
648
|
+
tokenHash,
|
|
649
|
+
expiresAt: expiresAt.toISOString(),
|
|
650
|
+
status: "pending",
|
|
651
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
652
|
+
message: input.message
|
|
653
|
+
};
|
|
654
|
+
await this.storage.set(
|
|
655
|
+
`invitation:hash:${tokenHash}`,
|
|
656
|
+
invitation,
|
|
657
|
+
this.config.expiresIn
|
|
658
|
+
);
|
|
659
|
+
await this.storage.set(
|
|
660
|
+
`invitation:id:${id}`,
|
|
661
|
+
invitation,
|
|
662
|
+
this.config.expiresIn
|
|
663
|
+
);
|
|
664
|
+
await this.storage.set(
|
|
665
|
+
`invitation:email:${normalizedEmail}:${input.tenantId}`,
|
|
666
|
+
id,
|
|
667
|
+
this.config.expiresIn
|
|
668
|
+
);
|
|
669
|
+
const params = new URLSearchParams({ token });
|
|
670
|
+
const invitationUrl = `${this.config.baseUrl}${this.config.callbackPath}?${params}`;
|
|
671
|
+
return {
|
|
672
|
+
success: true,
|
|
673
|
+
invitation,
|
|
674
|
+
invitationUrl,
|
|
675
|
+
token
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Accept an invitation
|
|
680
|
+
*/
|
|
681
|
+
async acceptInvitation(input) {
|
|
682
|
+
const tokenHash = await sha256Hex(input.token);
|
|
683
|
+
const invitation = await this.storage.get(`invitation:hash:${tokenHash}`);
|
|
684
|
+
if (!invitation) {
|
|
685
|
+
return { success: false, error: "Invalid or expired invitation" };
|
|
686
|
+
}
|
|
687
|
+
if (invitation.status !== "pending") {
|
|
688
|
+
return { success: false, error: `Invitation is ${invitation.status}` };
|
|
689
|
+
}
|
|
690
|
+
if (new Date(invitation.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
691
|
+
invitation.status = "expired";
|
|
692
|
+
await this.updateInvitation(invitation);
|
|
693
|
+
return { success: false, error: "Invitation has expired" };
|
|
694
|
+
}
|
|
695
|
+
const existingMembership = await this.adapter.findMembership?.(
|
|
696
|
+
input.userId,
|
|
697
|
+
invitation.tenantId
|
|
698
|
+
);
|
|
699
|
+
if (existingMembership && existingMembership.status === "active") {
|
|
700
|
+
return { success: false, error: "You are already a member of this tenant" };
|
|
701
|
+
}
|
|
702
|
+
let membership;
|
|
703
|
+
if (existingMembership) {
|
|
704
|
+
membership = await this.adapter.updateMembership(existingMembership.id, {
|
|
705
|
+
role: invitation.role,
|
|
706
|
+
permissions: invitation.permissions,
|
|
707
|
+
status: "active"
|
|
708
|
+
});
|
|
709
|
+
} else {
|
|
710
|
+
membership = await this.adapter.createMembership({
|
|
711
|
+
userId: input.userId,
|
|
712
|
+
tenantId: invitation.tenantId,
|
|
713
|
+
role: invitation.role,
|
|
714
|
+
permissions: invitation.permissions
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
invitation.status = "accepted";
|
|
718
|
+
invitation.acceptedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
719
|
+
invitation.acceptedBy = input.userId;
|
|
720
|
+
await this.updateInvitation(invitation);
|
|
721
|
+
const tenant = await this.adapter.findTenantById?.(invitation.tenantId);
|
|
722
|
+
return {
|
|
723
|
+
success: true,
|
|
724
|
+
membership,
|
|
725
|
+
tenant: tenant ?? void 0
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Check invitation status
|
|
730
|
+
*/
|
|
731
|
+
async checkInvitation(token) {
|
|
732
|
+
const tokenHash = await sha256Hex(token);
|
|
733
|
+
const invitation = await this.storage.get(`invitation:hash:${tokenHash}`);
|
|
734
|
+
if (!invitation) {
|
|
735
|
+
return { valid: false, error: "Invalid or expired invitation" };
|
|
736
|
+
}
|
|
737
|
+
if (invitation.status !== "pending") {
|
|
738
|
+
return { valid: false, error: `Invitation is ${invitation.status}`, invitation };
|
|
739
|
+
}
|
|
740
|
+
if (new Date(invitation.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
741
|
+
invitation.status = "expired";
|
|
742
|
+
await this.updateInvitation(invitation);
|
|
743
|
+
return { valid: false, error: "Invitation has expired", invitation };
|
|
744
|
+
}
|
|
745
|
+
const tenant = await this.adapter.findTenantById?.(invitation.tenantId);
|
|
746
|
+
return {
|
|
747
|
+
valid: true,
|
|
748
|
+
invitation,
|
|
749
|
+
tenant: tenant ?? void 0
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Cancel an invitation
|
|
754
|
+
*/
|
|
755
|
+
async cancelInvitation(invitationId) {
|
|
756
|
+
const invitation = await this.storage.get(`invitation:id:${invitationId}`);
|
|
757
|
+
if (!invitation) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
if (invitation.status !== "pending") {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
invitation.status = "cancelled";
|
|
764
|
+
await this.updateInvitation(invitation);
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Get invitation by ID
|
|
769
|
+
*/
|
|
770
|
+
async getInvitationById(id) {
|
|
771
|
+
return this.storage.get(`invitation:id:${id}`);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Get invitation by email and tenant
|
|
775
|
+
*/
|
|
776
|
+
async getInvitationByEmail(email, tenantId) {
|
|
777
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
778
|
+
const invitationId = await this.storage.get(
|
|
779
|
+
`invitation:email:${normalizedEmail}:${tenantId}`
|
|
780
|
+
);
|
|
781
|
+
if (!invitationId) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
return this.getInvitationById(invitationId);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Resend invitation (generates new token)
|
|
788
|
+
*/
|
|
789
|
+
async resendInvitation(invitationId, invitedBy) {
|
|
790
|
+
const invitation = await this.getInvitationById(invitationId);
|
|
791
|
+
if (!invitation) {
|
|
792
|
+
return { success: false, error: "Invitation not found" };
|
|
793
|
+
}
|
|
794
|
+
if (invitation.status !== "pending") {
|
|
795
|
+
return { success: false, error: `Cannot resend ${invitation.status} invitation` };
|
|
796
|
+
}
|
|
797
|
+
await this.cancelInvitation(invitationId);
|
|
798
|
+
return this.sendInvitation({
|
|
799
|
+
email: invitation.email,
|
|
800
|
+
tenantId: invitation.tenantId,
|
|
801
|
+
role: invitation.role,
|
|
802
|
+
permissions: invitation.permissions,
|
|
803
|
+
invitedBy,
|
|
804
|
+
message: invitation.message
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Get pending invitations for a tenant
|
|
809
|
+
* Note: This requires listing keys which may not be efficient for all storage backends
|
|
810
|
+
*/
|
|
811
|
+
async getPendingInvitations(_tenantId) {
|
|
812
|
+
console.warn(
|
|
813
|
+
"getPendingInvitations: Consider implementing list operation in storage"
|
|
814
|
+
);
|
|
815
|
+
return [];
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Update invitation record
|
|
819
|
+
*/
|
|
820
|
+
async updateInvitation(invitation) {
|
|
821
|
+
const ttl = Math.max(
|
|
822
|
+
0,
|
|
823
|
+
Math.floor((new Date(invitation.expiresAt).getTime() - Date.now()) / 1e3)
|
|
824
|
+
);
|
|
825
|
+
await this.storage.set(`invitation:id:${invitation.id}`, invitation, ttl || 300);
|
|
826
|
+
await this.storage.set(
|
|
827
|
+
`invitation:hash:${invitation.tokenHash}`,
|
|
828
|
+
invitation,
|
|
829
|
+
ttl || 300
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
function createInvitationService(storage, adapter, config) {
|
|
834
|
+
return new InvitationService(storage, adapter, config);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/core/auth-engine.ts
|
|
838
|
+
var ParsAuthEngine = class {
|
|
839
|
+
config;
|
|
840
|
+
storage;
|
|
841
|
+
providers;
|
|
842
|
+
jwtManager;
|
|
843
|
+
sessionBlocklist;
|
|
844
|
+
adapter;
|
|
845
|
+
callbacks;
|
|
846
|
+
initialized = false;
|
|
847
|
+
// Multi-tenant components
|
|
848
|
+
tenantManager;
|
|
849
|
+
tenantResolver;
|
|
850
|
+
invitationService;
|
|
851
|
+
constructor(config) {
|
|
852
|
+
validateConfig(config);
|
|
853
|
+
this.config = mergeConfig(config);
|
|
854
|
+
this.adapter = config.adapter;
|
|
855
|
+
this.callbacks = config.callbacks ?? {};
|
|
856
|
+
this.providers = new ProviderRegistry();
|
|
857
|
+
const sessionConfig = this.config.session;
|
|
858
|
+
const jwtIssuer = this.config.jwt.issuer;
|
|
859
|
+
const issuer = Array.isArray(jwtIssuer) ? jwtIssuer[0] ?? "pars-auth" : jwtIssuer ?? "pars-auth";
|
|
860
|
+
const jwtAudience = this.config.jwt.audience;
|
|
861
|
+
const audience = Array.isArray(jwtAudience) ? jwtAudience[0] ?? "pars-client" : jwtAudience ?? "pars-client";
|
|
862
|
+
this.jwtManager = new JwtManager({
|
|
863
|
+
secret: this.config.secret,
|
|
864
|
+
issuer,
|
|
865
|
+
audience,
|
|
866
|
+
accessTokenTTL: `${sessionConfig.accessTokenExpiry}s`,
|
|
867
|
+
refreshTokenTTL: `${sessionConfig.refreshTokenExpiry}s`
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Initialize the auth engine (async operations)
|
|
872
|
+
* Must be called before using the engine
|
|
873
|
+
*/
|
|
874
|
+
async initialize() {
|
|
875
|
+
if (this.initialized) return;
|
|
876
|
+
this.storage = await createStorage(this.config.storage);
|
|
877
|
+
this.sessionBlocklist = new SessionBlocklist(this.storage);
|
|
878
|
+
if (this.config.providers.otp?.enabled !== false) {
|
|
879
|
+
const otpProvider = new OTPProvider(this.storage, this.config.providers.otp);
|
|
880
|
+
this.providers.register(otpProvider);
|
|
881
|
+
}
|
|
882
|
+
this.tenantManager = createTenantManager(this.adapter);
|
|
883
|
+
if (this.config.tenant?.enabled !== false) {
|
|
884
|
+
this.tenantResolver = createTenantResolver({
|
|
885
|
+
strategy: this.config.tenant.strategy ?? "header",
|
|
886
|
+
headerName: this.config.tenant.headerName ?? "x-tenant-id"
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
if (this.config.baseUrl) {
|
|
890
|
+
this.invitationService = createInvitationService(
|
|
891
|
+
this.storage,
|
|
892
|
+
this.adapter,
|
|
893
|
+
{ baseUrl: this.config.baseUrl }
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
this.initialized = true;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Ensure engine is initialized
|
|
900
|
+
*/
|
|
901
|
+
ensureInitialized() {
|
|
902
|
+
if (!this.initialized) {
|
|
903
|
+
throw new Error("[Pars Auth] Engine not initialized. Call initialize() first.");
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Get all registered providers
|
|
908
|
+
*/
|
|
909
|
+
getProviders() {
|
|
910
|
+
return this.providers.getEnabled().map((p) => p.getInfo());
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Check if a provider is enabled
|
|
914
|
+
*/
|
|
915
|
+
isProviderEnabled(name) {
|
|
916
|
+
const provider = this.providers.get(name);
|
|
917
|
+
return provider?.enabled ?? false;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Request OTP (for OTP provider)
|
|
921
|
+
*/
|
|
922
|
+
async requestOTP(input) {
|
|
923
|
+
this.ensureInitialized();
|
|
924
|
+
const otpProvider = this.providers.get("otp");
|
|
925
|
+
if (!otpProvider) {
|
|
926
|
+
return {
|
|
927
|
+
success: false,
|
|
928
|
+
error: "OTP provider not enabled"
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
return otpProvider.requestOTP(input);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Sign in with any provider
|
|
935
|
+
*/
|
|
936
|
+
async signIn(input) {
|
|
937
|
+
this.ensureInitialized();
|
|
938
|
+
const { provider: providerName, identifier, credential, data, metadata } = input;
|
|
939
|
+
const provider = this.providers.get(providerName);
|
|
940
|
+
if (!provider) {
|
|
941
|
+
return {
|
|
942
|
+
success: false,
|
|
943
|
+
error: `Provider '${providerName}' not found`,
|
|
944
|
+
errorCode: "PROVIDER_NOT_FOUND"
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
if (!provider.enabled) {
|
|
948
|
+
return {
|
|
949
|
+
success: false,
|
|
950
|
+
error: `Provider '${providerName}' is not enabled`,
|
|
951
|
+
errorCode: "PROVIDER_DISABLED"
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
const authResult = await provider.authenticate({
|
|
955
|
+
identifier,
|
|
956
|
+
credential: credential ?? "",
|
|
957
|
+
data
|
|
958
|
+
});
|
|
959
|
+
if (!authResult.success) {
|
|
960
|
+
return {
|
|
961
|
+
success: false,
|
|
962
|
+
error: authResult.error,
|
|
963
|
+
errorCode: authResult.errorCode
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
let user = await this.findUserByIdentifier(identifier, providerName);
|
|
967
|
+
if (!user) {
|
|
968
|
+
if (providerName === "otp") {
|
|
969
|
+
const otpType = data?.["type"];
|
|
970
|
+
const createResult = await this.signUp({
|
|
971
|
+
email: otpType === "email" || !otpType ? identifier : void 0,
|
|
972
|
+
phone: otpType === "sms" ? identifier : void 0,
|
|
973
|
+
metadata
|
|
974
|
+
});
|
|
975
|
+
if (!createResult.success) {
|
|
976
|
+
return {
|
|
977
|
+
success: false,
|
|
978
|
+
error: createResult.error,
|
|
979
|
+
errorCode: createResult.errorCode
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
user = createResult.user;
|
|
983
|
+
} else {
|
|
984
|
+
return {
|
|
985
|
+
success: false,
|
|
986
|
+
error: "User not found",
|
|
987
|
+
errorCode: "USER_NOT_FOUND"
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (this.callbacks.validateSignIn) {
|
|
992
|
+
const allowed = await this.callbacks.validateSignIn(user);
|
|
993
|
+
if (!allowed) {
|
|
994
|
+
return {
|
|
995
|
+
success: false,
|
|
996
|
+
error: "Sign in not allowed",
|
|
997
|
+
errorCode: "SIGN_IN_REJECTED"
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (user.twoFactorEnabled && providerName !== "totp") {
|
|
1002
|
+
return {
|
|
1003
|
+
success: true,
|
|
1004
|
+
user,
|
|
1005
|
+
requiresTwoFactor: true,
|
|
1006
|
+
twoFactorChallengeId: ""
|
|
1007
|
+
// Generate challenge ID
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
const session = await this.createSession(user.id, metadata);
|
|
1011
|
+
if (!session) {
|
|
1012
|
+
return {
|
|
1013
|
+
success: false,
|
|
1014
|
+
error: "Failed to create session",
|
|
1015
|
+
errorCode: "SESSION_CREATE_FAILED"
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const tokens = await this.jwtManager.generateTokenPair({
|
|
1019
|
+
userId: user.id,
|
|
1020
|
+
tenantId: metadata?.tenantId,
|
|
1021
|
+
sessionId: session.id
|
|
1022
|
+
});
|
|
1023
|
+
if (this.callbacks.onSignIn) {
|
|
1024
|
+
await this.callbacks.onSignIn(user, session);
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
success: true,
|
|
1028
|
+
user,
|
|
1029
|
+
session,
|
|
1030
|
+
tokens
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Sign up a new user
|
|
1035
|
+
*/
|
|
1036
|
+
async signUp(input) {
|
|
1037
|
+
this.ensureInitialized();
|
|
1038
|
+
const { email, phone, name, avatar, metadata } = input;
|
|
1039
|
+
if (!email && !phone) {
|
|
1040
|
+
return {
|
|
1041
|
+
success: false,
|
|
1042
|
+
error: "Email or phone is required",
|
|
1043
|
+
errorCode: "INVALID_INPUT"
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
if (email) {
|
|
1047
|
+
const existing = await this.adapter.findUserByEmail(email);
|
|
1048
|
+
if (existing) {
|
|
1049
|
+
return {
|
|
1050
|
+
success: false,
|
|
1051
|
+
error: "User with this email already exists",
|
|
1052
|
+
errorCode: "USER_EXISTS"
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (phone) {
|
|
1057
|
+
const existing = await this.adapter.findUserByPhone(phone);
|
|
1058
|
+
if (existing) {
|
|
1059
|
+
return {
|
|
1060
|
+
success: false,
|
|
1061
|
+
error: "User with this phone already exists",
|
|
1062
|
+
errorCode: "USER_EXISTS"
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
const user = await this.adapter.createUser({
|
|
1067
|
+
email,
|
|
1068
|
+
phone,
|
|
1069
|
+
name,
|
|
1070
|
+
avatar,
|
|
1071
|
+
emailVerified: !!email,
|
|
1072
|
+
// OTP verification counts as email verification
|
|
1073
|
+
phoneVerified: !!phone
|
|
1074
|
+
});
|
|
1075
|
+
const session = await this.createSession(user.id, metadata);
|
|
1076
|
+
const tokens = session ? await this.jwtManager.generateTokenPair({
|
|
1077
|
+
userId: user.id,
|
|
1078
|
+
tenantId: metadata?.tenantId,
|
|
1079
|
+
sessionId: session.id
|
|
1080
|
+
}) : void 0;
|
|
1081
|
+
if (this.callbacks.onSignUp) {
|
|
1082
|
+
await this.callbacks.onSignUp(user);
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
success: true,
|
|
1086
|
+
user,
|
|
1087
|
+
session: session ?? void 0,
|
|
1088
|
+
tokens
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Sign out (revoke session)
|
|
1093
|
+
*/
|
|
1094
|
+
async signOut(sessionId, options) {
|
|
1095
|
+
this.ensureInitialized();
|
|
1096
|
+
if (options?.revokeAll && options?.userId) {
|
|
1097
|
+
const sessions = await this.adapter.findSessionsByUserId(options.userId);
|
|
1098
|
+
await this.adapter.deleteSessionsByUserId(options.userId);
|
|
1099
|
+
const tokenExpiry = new Date(
|
|
1100
|
+
Date.now() + this.config.session.refreshTokenExpiry * 1e3
|
|
1101
|
+
);
|
|
1102
|
+
await this.sessionBlocklist.blockAllUserSessions(
|
|
1103
|
+
options.userId,
|
|
1104
|
+
sessions.map((s) => s.id),
|
|
1105
|
+
tokenExpiry,
|
|
1106
|
+
"Sign out all"
|
|
1107
|
+
);
|
|
1108
|
+
if (this.callbacks.onSignOut) {
|
|
1109
|
+
for (const session of sessions) {
|
|
1110
|
+
await this.callbacks.onSignOut(options.userId, session.id);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} else {
|
|
1114
|
+
const session = await this.adapter.findSessionById(sessionId);
|
|
1115
|
+
if (session) {
|
|
1116
|
+
await this.adapter.deleteSession(sessionId);
|
|
1117
|
+
const tokenExpiry = new Date(
|
|
1118
|
+
Date.now() + this.config.session.refreshTokenExpiry * 1e3
|
|
1119
|
+
);
|
|
1120
|
+
await this.sessionBlocklist.blockSession(sessionId, tokenExpiry, {
|
|
1121
|
+
reason: "Sign out",
|
|
1122
|
+
userId: session.userId
|
|
1123
|
+
});
|
|
1124
|
+
if (this.callbacks.onSignOut) {
|
|
1125
|
+
await this.callbacks.onSignOut(session.userId, sessionId);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Verify access token
|
|
1132
|
+
*/
|
|
1133
|
+
async verifyAccessToken(token) {
|
|
1134
|
+
this.ensureInitialized();
|
|
1135
|
+
try {
|
|
1136
|
+
const payload = await this.jwtManager.verifyAccessToken(token);
|
|
1137
|
+
if (payload.sid) {
|
|
1138
|
+
const isBlocked = await this.sessionBlocklist.isBlocked(payload.sid);
|
|
1139
|
+
if (isBlocked) {
|
|
1140
|
+
return {
|
|
1141
|
+
valid: false,
|
|
1142
|
+
error: "Session has been revoked"
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
valid: true,
|
|
1148
|
+
payload
|
|
1149
|
+
};
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
return {
|
|
1152
|
+
valid: false,
|
|
1153
|
+
error: error instanceof Error ? error.message : "Invalid token"
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Refresh tokens
|
|
1159
|
+
*/
|
|
1160
|
+
async refreshTokens(refreshToken) {
|
|
1161
|
+
this.ensureInitialized();
|
|
1162
|
+
try {
|
|
1163
|
+
const { userId, sessionId, tenantId } = await this.jwtManager.verifyRefreshToken(refreshToken);
|
|
1164
|
+
if (sessionId) {
|
|
1165
|
+
const isBlocked = await this.sessionBlocklist.isBlocked(sessionId);
|
|
1166
|
+
if (isBlocked) {
|
|
1167
|
+
return {
|
|
1168
|
+
success: false,
|
|
1169
|
+
error: "Session has been revoked"
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (sessionId) {
|
|
1174
|
+
const session = await this.adapter.findSessionById(sessionId);
|
|
1175
|
+
if (!session || session.status !== "active") {
|
|
1176
|
+
return {
|
|
1177
|
+
success: false,
|
|
1178
|
+
error: "Session not found or inactive"
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
if (this.config.session.slidingWindow) {
|
|
1182
|
+
const newExpiresAt = new Date(
|
|
1183
|
+
Date.now() + this.config.session.refreshTokenExpiry * 1e3
|
|
1184
|
+
);
|
|
1185
|
+
await this.adapter.updateSession(sessionId, {
|
|
1186
|
+
expiresAt: newExpiresAt
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const tokens = await this.jwtManager.generateTokenPair({
|
|
1191
|
+
userId,
|
|
1192
|
+
tenantId,
|
|
1193
|
+
sessionId
|
|
1194
|
+
});
|
|
1195
|
+
return {
|
|
1196
|
+
success: true,
|
|
1197
|
+
tokens
|
|
1198
|
+
};
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
return {
|
|
1201
|
+
success: false,
|
|
1202
|
+
error: error instanceof Error ? error.message : "Invalid refresh token"
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Get user sessions
|
|
1208
|
+
*/
|
|
1209
|
+
async getSessions(userId, currentSessionId) {
|
|
1210
|
+
this.ensureInitialized();
|
|
1211
|
+
const sessions = await this.adapter.findSessionsByUserId(userId);
|
|
1212
|
+
return sessions.filter((s) => s.status === "active").map((s) => ({
|
|
1213
|
+
id: s.id,
|
|
1214
|
+
userId: s.userId,
|
|
1215
|
+
tenantId: s.tenantId ?? void 0,
|
|
1216
|
+
deviceType: s.deviceType ?? void 0,
|
|
1217
|
+
deviceName: s.deviceName ?? void 0,
|
|
1218
|
+
ipAddress: s.ipAddress ?? void 0,
|
|
1219
|
+
createdAt: s.createdAt,
|
|
1220
|
+
expiresAt: s.expiresAt,
|
|
1221
|
+
isCurrent: s.id === currentSessionId
|
|
1222
|
+
}));
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Revoke a specific session
|
|
1226
|
+
*/
|
|
1227
|
+
async revokeSession(sessionId) {
|
|
1228
|
+
await this.signOut(sessionId);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Revoke all sessions for a user
|
|
1232
|
+
*/
|
|
1233
|
+
async revokeAllSessions(userId) {
|
|
1234
|
+
await this.signOut("", { revokeAll: true, userId });
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Get the underlying storage instance
|
|
1238
|
+
*/
|
|
1239
|
+
getStorage() {
|
|
1240
|
+
this.ensureInitialized();
|
|
1241
|
+
return this.storage;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Get the JWT manager
|
|
1245
|
+
*/
|
|
1246
|
+
getJwtManager() {
|
|
1247
|
+
return this.jwtManager;
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Get the database adapter
|
|
1251
|
+
*/
|
|
1252
|
+
getAdapter() {
|
|
1253
|
+
return this.adapter;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Get configuration
|
|
1257
|
+
*/
|
|
1258
|
+
getConfig() {
|
|
1259
|
+
return this.config;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Find user by identifier based on provider
|
|
1263
|
+
*/
|
|
1264
|
+
async findUserByIdentifier(identifier, provider) {
|
|
1265
|
+
switch (provider) {
|
|
1266
|
+
case "otp":
|
|
1267
|
+
const userByEmail = await this.adapter.findUserByEmail(identifier);
|
|
1268
|
+
if (userByEmail) return userByEmail;
|
|
1269
|
+
return this.adapter.findUserByPhone(identifier);
|
|
1270
|
+
case "password":
|
|
1271
|
+
return this.adapter.findUserByEmail(identifier);
|
|
1272
|
+
default:
|
|
1273
|
+
const authMethod = await this.adapter.findAuthMethod(provider, identifier);
|
|
1274
|
+
if (authMethod) {
|
|
1275
|
+
return this.adapter.findUserById(authMethod.userId);
|
|
1276
|
+
}
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Create a new session
|
|
1282
|
+
*/
|
|
1283
|
+
async createSession(userId, metadata) {
|
|
1284
|
+
const existingSessions = await this.adapter.findSessionsByUserId(userId);
|
|
1285
|
+
const activeSessions = existingSessions.filter((s) => s.status === "active");
|
|
1286
|
+
if (activeSessions.length >= this.config.session.maxSessions) {
|
|
1287
|
+
const oldest = activeSessions.sort(
|
|
1288
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
1289
|
+
)[0];
|
|
1290
|
+
if (oldest) {
|
|
1291
|
+
await this.adapter.deleteSession(oldest.id);
|
|
1292
|
+
await this.sessionBlocklist.blockSession(
|
|
1293
|
+
oldest.id,
|
|
1294
|
+
new Date(Date.now() + this.config.session.refreshTokenExpiry * 1e3),
|
|
1295
|
+
{ reason: "Session limit reached", userId }
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
const expiresAt = new Date(
|
|
1300
|
+
Date.now() + this.config.session.accessTokenExpiry * 1e3
|
|
1301
|
+
);
|
|
1302
|
+
const refreshExpiresAt = new Date(
|
|
1303
|
+
Date.now() + this.config.session.refreshTokenExpiry * 1e3
|
|
1304
|
+
);
|
|
1305
|
+
const session = await this.adapter.createSession({
|
|
1306
|
+
userId,
|
|
1307
|
+
tenantId: metadata?.tenantId,
|
|
1308
|
+
expiresAt,
|
|
1309
|
+
refreshExpiresAt,
|
|
1310
|
+
deviceType: metadata?.deviceType,
|
|
1311
|
+
deviceName: metadata?.deviceName,
|
|
1312
|
+
userAgent: metadata?.userAgent,
|
|
1313
|
+
ipAddress: metadata?.ipAddress
|
|
1314
|
+
});
|
|
1315
|
+
if (this.callbacks.onSessionCreated) {
|
|
1316
|
+
await this.callbacks.onSessionCreated({ id: session.id, userId });
|
|
1317
|
+
}
|
|
1318
|
+
return session;
|
|
1319
|
+
}
|
|
1320
|
+
// ============================================
|
|
1321
|
+
// MULTI-TENANT OPERATIONS
|
|
1322
|
+
// ============================================
|
|
1323
|
+
/**
|
|
1324
|
+
* Resolve tenant from request
|
|
1325
|
+
*/
|
|
1326
|
+
async resolveTenant(request) {
|
|
1327
|
+
this.ensureInitialized();
|
|
1328
|
+
if (!this.tenantResolver) {
|
|
1329
|
+
return { tenantId: null, resolvedFrom: null };
|
|
1330
|
+
}
|
|
1331
|
+
return this.tenantResolver.resolve(request);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Switch current session to a different tenant
|
|
1335
|
+
*/
|
|
1336
|
+
async switchTenant(sessionId, targetTenantId) {
|
|
1337
|
+
this.ensureInitialized();
|
|
1338
|
+
const session = await this.adapter.findSessionById(sessionId);
|
|
1339
|
+
if (!session) {
|
|
1340
|
+
return { success: false, error: "Session not found" };
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
await this.tenantManager.validateTenantSwitch(session.userId, targetTenantId);
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
return {
|
|
1346
|
+
success: false,
|
|
1347
|
+
error: error instanceof Error ? error.message : "Tenant switch not allowed"
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
await this.adapter.updateSession(sessionId, {
|
|
1351
|
+
tenantId: targetTenantId
|
|
1352
|
+
});
|
|
1353
|
+
const tokens = await this.jwtManager.generateTokenPair({
|
|
1354
|
+
userId: session.userId,
|
|
1355
|
+
tenantId: targetTenantId,
|
|
1356
|
+
sessionId: session.id
|
|
1357
|
+
});
|
|
1358
|
+
return { success: true, tokens };
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Get all tenants for current user
|
|
1362
|
+
*/
|
|
1363
|
+
async getUserTenants(userId) {
|
|
1364
|
+
this.ensureInitialized();
|
|
1365
|
+
const memberships = await this.tenantManager.getUserTenants(userId);
|
|
1366
|
+
return memberships.filter((m) => m.tenant && m.status === "active").map((m) => ({
|
|
1367
|
+
tenant: m.tenant,
|
|
1368
|
+
membership: m
|
|
1369
|
+
}));
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Check if user is member of tenant
|
|
1373
|
+
*/
|
|
1374
|
+
async isTenantMember(userId, tenantId) {
|
|
1375
|
+
this.ensureInitialized();
|
|
1376
|
+
return this.tenantManager.isMember(userId, tenantId);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Check if user has role in tenant
|
|
1380
|
+
*/
|
|
1381
|
+
async hasRoleInTenant(userId, tenantId, role) {
|
|
1382
|
+
this.ensureInitialized();
|
|
1383
|
+
return this.tenantManager.hasRole(userId, tenantId, role);
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Get user's membership in a tenant
|
|
1387
|
+
*/
|
|
1388
|
+
async getTenantMembership(userId, tenantId) {
|
|
1389
|
+
this.ensureInitialized();
|
|
1390
|
+
return this.tenantManager.getMembership(userId, tenantId);
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Add member to tenant
|
|
1394
|
+
*/
|
|
1395
|
+
async addTenantMember(input) {
|
|
1396
|
+
this.ensureInitialized();
|
|
1397
|
+
return this.tenantManager.addMember(input);
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Update member's role/permissions in tenant
|
|
1401
|
+
*/
|
|
1402
|
+
async updateTenantMember(userId, tenantId, updates) {
|
|
1403
|
+
this.ensureInitialized();
|
|
1404
|
+
return this.tenantManager.updateMember(userId, tenantId, updates);
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Remove member from tenant
|
|
1408
|
+
*/
|
|
1409
|
+
async removeTenantMember(userId, tenantId) {
|
|
1410
|
+
this.ensureInitialized();
|
|
1411
|
+
return this.tenantManager.removeMember(userId, tenantId);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Send invitation to join tenant
|
|
1415
|
+
*/
|
|
1416
|
+
async inviteToTenant(input) {
|
|
1417
|
+
this.ensureInitialized();
|
|
1418
|
+
if (!this.invitationService) {
|
|
1419
|
+
return { success: false, error: "Invitation service not configured. Set baseUrl in config." };
|
|
1420
|
+
}
|
|
1421
|
+
return this.invitationService.sendInvitation(input);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Accept an invitation
|
|
1425
|
+
*/
|
|
1426
|
+
async acceptInvitation(token, userId) {
|
|
1427
|
+
this.ensureInitialized();
|
|
1428
|
+
if (!this.invitationService) {
|
|
1429
|
+
return { success: false, error: "Invitation service not configured" };
|
|
1430
|
+
}
|
|
1431
|
+
return this.invitationService.acceptInvitation({ token, userId });
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Check invitation status
|
|
1435
|
+
*/
|
|
1436
|
+
async checkInvitation(token) {
|
|
1437
|
+
this.ensureInitialized();
|
|
1438
|
+
if (!this.invitationService) {
|
|
1439
|
+
return { valid: false, error: "Invitation service not configured" };
|
|
1440
|
+
}
|
|
1441
|
+
const result = await this.invitationService.checkInvitation(token);
|
|
1442
|
+
if (!result.valid) {
|
|
1443
|
+
return { valid: false, error: result.error };
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
valid: true,
|
|
1447
|
+
tenantName: result.tenant?.name,
|
|
1448
|
+
role: result.invitation?.role,
|
|
1449
|
+
email: result.invitation?.email
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Get tenant manager instance
|
|
1454
|
+
*/
|
|
1455
|
+
getTenantManager() {
|
|
1456
|
+
this.ensureInitialized();
|
|
1457
|
+
return this.tenantManager;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Get invitation service instance
|
|
1461
|
+
*/
|
|
1462
|
+
getInvitationService() {
|
|
1463
|
+
this.ensureInitialized();
|
|
1464
|
+
return this.invitationService;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Get tenant resolver instance
|
|
1468
|
+
*/
|
|
1469
|
+
getTenantResolver() {
|
|
1470
|
+
return this.tenantResolver;
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
function createAuthEngine(config) {
|
|
1474
|
+
return new ParsAuthEngine(config);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// src/providers/oauth/google.ts
|
|
1478
|
+
var GoogleProvider = class {
|
|
1479
|
+
name = "google";
|
|
1480
|
+
config;
|
|
1481
|
+
constructor(config) {
|
|
1482
|
+
this.config = config;
|
|
1483
|
+
}
|
|
1484
|
+
async getAuthorizationUrl(state, codeChallenge) {
|
|
1485
|
+
const params = new URLSearchParams({
|
|
1486
|
+
client_id: this.config.clientId,
|
|
1487
|
+
redirect_uri: this.config.redirectUri,
|
|
1488
|
+
response_type: "code",
|
|
1489
|
+
scope: this.config.scopes?.join(" ") ?? "openid email profile",
|
|
1490
|
+
state,
|
|
1491
|
+
access_type: "offline",
|
|
1492
|
+
prompt: "consent"
|
|
1493
|
+
});
|
|
1494
|
+
if (codeChallenge) {
|
|
1495
|
+
params.set("code_challenge", codeChallenge);
|
|
1496
|
+
params.set("code_challenge_method", "S256");
|
|
1497
|
+
}
|
|
1498
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
1499
|
+
}
|
|
1500
|
+
async exchangeCode(code, codeVerifier) {
|
|
1501
|
+
const params = {
|
|
1502
|
+
client_id: this.config.clientId,
|
|
1503
|
+
client_secret: this.config.clientSecret,
|
|
1504
|
+
code,
|
|
1505
|
+
grant_type: "authorization_code",
|
|
1506
|
+
redirect_uri: this.config.redirectUri
|
|
1507
|
+
};
|
|
1508
|
+
if (codeVerifier) {
|
|
1509
|
+
params["code_verifier"] = codeVerifier;
|
|
1510
|
+
}
|
|
1511
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
1512
|
+
method: "POST",
|
|
1513
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1514
|
+
body: new URLSearchParams(params)
|
|
1515
|
+
});
|
|
1516
|
+
if (!response.ok) {
|
|
1517
|
+
const error = await response.text();
|
|
1518
|
+
throw new Error(`Google token exchange failed: ${error}`);
|
|
1519
|
+
}
|
|
1520
|
+
const data = await response.json();
|
|
1521
|
+
return {
|
|
1522
|
+
accessToken: data["access_token"],
|
|
1523
|
+
refreshToken: data["refresh_token"],
|
|
1524
|
+
expiresIn: data["expires_in"],
|
|
1525
|
+
idToken: data["id_token"],
|
|
1526
|
+
tokenType: data["token_type"],
|
|
1527
|
+
scope: data["scope"]
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
async getUserInfo(accessToken) {
|
|
1531
|
+
const response = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
|
|
1532
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1533
|
+
});
|
|
1534
|
+
if (!response.ok) {
|
|
1535
|
+
throw new Error("Failed to fetch Google user info");
|
|
1536
|
+
}
|
|
1537
|
+
const data = await response.json();
|
|
1538
|
+
return {
|
|
1539
|
+
id: data["sub"],
|
|
1540
|
+
email: data["email"],
|
|
1541
|
+
name: data["name"],
|
|
1542
|
+
avatarUrl: data["picture"],
|
|
1543
|
+
emailVerified: data["email_verified"] ?? false,
|
|
1544
|
+
raw: data
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
async refreshToken(refreshToken) {
|
|
1548
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
1549
|
+
method: "POST",
|
|
1550
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1551
|
+
body: new URLSearchParams({
|
|
1552
|
+
client_id: this.config.clientId,
|
|
1553
|
+
client_secret: this.config.clientSecret,
|
|
1554
|
+
refresh_token: refreshToken,
|
|
1555
|
+
grant_type: "refresh_token"
|
|
1556
|
+
})
|
|
1557
|
+
});
|
|
1558
|
+
if (!response.ok) {
|
|
1559
|
+
const error = await response.text();
|
|
1560
|
+
throw new Error(`Google token refresh failed: ${error}`);
|
|
1561
|
+
}
|
|
1562
|
+
const data = await response.json();
|
|
1563
|
+
return {
|
|
1564
|
+
accessToken: data["access_token"],
|
|
1565
|
+
refreshToken: data["refresh_token"] ?? refreshToken,
|
|
1566
|
+
expiresIn: data["expires_in"],
|
|
1567
|
+
idToken: data["id_token"],
|
|
1568
|
+
tokenType: data["token_type"],
|
|
1569
|
+
scope: data["scope"]
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
// src/providers/oauth/github.ts
|
|
1575
|
+
var GitHubProvider = class {
|
|
1576
|
+
name = "github";
|
|
1577
|
+
config;
|
|
1578
|
+
constructor(config) {
|
|
1579
|
+
this.config = config;
|
|
1580
|
+
}
|
|
1581
|
+
async getAuthorizationUrl(state, _codeChallenge) {
|
|
1582
|
+
const params = new URLSearchParams({
|
|
1583
|
+
client_id: this.config.clientId,
|
|
1584
|
+
redirect_uri: this.config.redirectUri,
|
|
1585
|
+
scope: this.config.scopes?.join(" ") ?? "read:user user:email",
|
|
1586
|
+
state
|
|
1587
|
+
});
|
|
1588
|
+
return `https://github.com/login/oauth/authorize?${params}`;
|
|
1589
|
+
}
|
|
1590
|
+
async exchangeCode(code, _codeVerifier) {
|
|
1591
|
+
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
1592
|
+
method: "POST",
|
|
1593
|
+
headers: {
|
|
1594
|
+
"Content-Type": "application/json",
|
|
1595
|
+
Accept: "application/json"
|
|
1596
|
+
},
|
|
1597
|
+
body: JSON.stringify({
|
|
1598
|
+
client_id: this.config.clientId,
|
|
1599
|
+
client_secret: this.config.clientSecret,
|
|
1600
|
+
code,
|
|
1601
|
+
redirect_uri: this.config.redirectUri
|
|
1602
|
+
})
|
|
1603
|
+
});
|
|
1604
|
+
if (!response.ok) {
|
|
1605
|
+
const error = await response.text();
|
|
1606
|
+
throw new Error(`GitHub token exchange failed: ${error}`);
|
|
1607
|
+
}
|
|
1608
|
+
const data = await response.json();
|
|
1609
|
+
if (data["error"]) {
|
|
1610
|
+
throw new Error(`GitHub OAuth error: ${data["error_description"] ?? data["error"]}`);
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
accessToken: data["access_token"],
|
|
1614
|
+
tokenType: data["token_type"],
|
|
1615
|
+
scope: data["scope"]
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
async getUserInfo(accessToken) {
|
|
1619
|
+
const userResponse = await fetch("https://api.github.com/user", {
|
|
1620
|
+
headers: {
|
|
1621
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1622
|
+
Accept: "application/vnd.github.v3+json"
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
if (!userResponse.ok) {
|
|
1626
|
+
throw new Error("Failed to fetch GitHub user info");
|
|
1627
|
+
}
|
|
1628
|
+
const userData = await userResponse.json();
|
|
1629
|
+
let email = userData["email"];
|
|
1630
|
+
let emailVerified = false;
|
|
1631
|
+
if (!email) {
|
|
1632
|
+
try {
|
|
1633
|
+
const emailsResponse = await fetch("https://api.github.com/user/emails", {
|
|
1634
|
+
headers: {
|
|
1635
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1636
|
+
Accept: "application/vnd.github.v3+json"
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
if (emailsResponse.ok) {
|
|
1640
|
+
const emails = await emailsResponse.json();
|
|
1641
|
+
const primaryEmail = emails.find((e) => e.primary && e.verified);
|
|
1642
|
+
if (primaryEmail) {
|
|
1643
|
+
email = primaryEmail.email;
|
|
1644
|
+
emailVerified = primaryEmail.verified;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
} catch {
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return {
|
|
1651
|
+
id: String(userData["id"]),
|
|
1652
|
+
email: email ?? "",
|
|
1653
|
+
name: userData["name"] ?? userData["login"],
|
|
1654
|
+
avatarUrl: userData["avatar_url"],
|
|
1655
|
+
emailVerified,
|
|
1656
|
+
raw: userData
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
// src/providers/oauth/microsoft.ts
|
|
1662
|
+
var MicrosoftProvider = class {
|
|
1663
|
+
name = "microsoft";
|
|
1664
|
+
config;
|
|
1665
|
+
baseUrl;
|
|
1666
|
+
constructor(config) {
|
|
1667
|
+
this.config = config;
|
|
1668
|
+
const tenant = config.tenantId ?? "common";
|
|
1669
|
+
this.baseUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0`;
|
|
1670
|
+
}
|
|
1671
|
+
async getAuthorizationUrl(state, codeChallenge) {
|
|
1672
|
+
const params = new URLSearchParams({
|
|
1673
|
+
client_id: this.config.clientId,
|
|
1674
|
+
redirect_uri: this.config.redirectUri,
|
|
1675
|
+
response_type: "code",
|
|
1676
|
+
scope: this.config.scopes?.join(" ") ?? "openid email profile User.Read",
|
|
1677
|
+
state,
|
|
1678
|
+
response_mode: "query"
|
|
1679
|
+
});
|
|
1680
|
+
if (codeChallenge) {
|
|
1681
|
+
params.set("code_challenge", codeChallenge);
|
|
1682
|
+
params.set("code_challenge_method", "S256");
|
|
1683
|
+
}
|
|
1684
|
+
return `${this.baseUrl}/authorize?${params}`;
|
|
1685
|
+
}
|
|
1686
|
+
async exchangeCode(code, codeVerifier) {
|
|
1687
|
+
const params = {
|
|
1688
|
+
client_id: this.config.clientId,
|
|
1689
|
+
client_secret: this.config.clientSecret,
|
|
1690
|
+
code,
|
|
1691
|
+
grant_type: "authorization_code",
|
|
1692
|
+
redirect_uri: this.config.redirectUri
|
|
1693
|
+
};
|
|
1694
|
+
if (codeVerifier) {
|
|
1695
|
+
params["code_verifier"] = codeVerifier;
|
|
1696
|
+
}
|
|
1697
|
+
const response = await fetch(`${this.baseUrl}/token`, {
|
|
1698
|
+
method: "POST",
|
|
1699
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1700
|
+
body: new URLSearchParams(params)
|
|
1701
|
+
});
|
|
1702
|
+
if (!response.ok) {
|
|
1703
|
+
const error = await response.text();
|
|
1704
|
+
throw new Error(`Microsoft token exchange failed: ${error}`);
|
|
1705
|
+
}
|
|
1706
|
+
const data = await response.json();
|
|
1707
|
+
return {
|
|
1708
|
+
accessToken: data["access_token"],
|
|
1709
|
+
refreshToken: data["refresh_token"],
|
|
1710
|
+
expiresIn: data["expires_in"],
|
|
1711
|
+
idToken: data["id_token"],
|
|
1712
|
+
tokenType: data["token_type"],
|
|
1713
|
+
scope: data["scope"]
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
async getUserInfo(accessToken) {
|
|
1717
|
+
const response = await fetch("https://graph.microsoft.com/v1.0/me", {
|
|
1718
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1719
|
+
});
|
|
1720
|
+
if (!response.ok) {
|
|
1721
|
+
throw new Error("Failed to fetch Microsoft user info");
|
|
1722
|
+
}
|
|
1723
|
+
const data = await response.json();
|
|
1724
|
+
return {
|
|
1725
|
+
id: data["id"],
|
|
1726
|
+
email: data["mail"] ?? data["userPrincipalName"],
|
|
1727
|
+
name: data["displayName"],
|
|
1728
|
+
avatarUrl: void 0,
|
|
1729
|
+
// Would need separate call to get photo
|
|
1730
|
+
emailVerified: true,
|
|
1731
|
+
// Microsoft accounts are verified
|
|
1732
|
+
raw: data
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
async refreshToken(refreshToken) {
|
|
1736
|
+
const response = await fetch(`${this.baseUrl}/token`, {
|
|
1737
|
+
method: "POST",
|
|
1738
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1739
|
+
body: new URLSearchParams({
|
|
1740
|
+
client_id: this.config.clientId,
|
|
1741
|
+
client_secret: this.config.clientSecret,
|
|
1742
|
+
refresh_token: refreshToken,
|
|
1743
|
+
grant_type: "refresh_token",
|
|
1744
|
+
scope: this.config.scopes?.join(" ") ?? "openid email profile User.Read"
|
|
1745
|
+
})
|
|
1746
|
+
});
|
|
1747
|
+
if (!response.ok) {
|
|
1748
|
+
const error = await response.text();
|
|
1749
|
+
throw new Error(`Microsoft token refresh failed: ${error}`);
|
|
1750
|
+
}
|
|
1751
|
+
const data = await response.json();
|
|
1752
|
+
return {
|
|
1753
|
+
accessToken: data["access_token"],
|
|
1754
|
+
refreshToken: data["refresh_token"] ?? refreshToken,
|
|
1755
|
+
expiresIn: data["expires_in"],
|
|
1756
|
+
idToken: data["id_token"],
|
|
1757
|
+
tokenType: data["token_type"],
|
|
1758
|
+
scope: data["scope"]
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
|
|
1763
|
+
// src/providers/oauth/apple.ts
|
|
1764
|
+
var AppleProvider = class {
|
|
1765
|
+
name = "apple";
|
|
1766
|
+
config;
|
|
1767
|
+
constructor(config) {
|
|
1768
|
+
this.config = config;
|
|
1769
|
+
}
|
|
1770
|
+
async getAuthorizationUrl(state, _codeChallenge) {
|
|
1771
|
+
const params = new URLSearchParams({
|
|
1772
|
+
client_id: this.config.clientId,
|
|
1773
|
+
redirect_uri: this.config.redirectUri,
|
|
1774
|
+
response_type: "code id_token",
|
|
1775
|
+
response_mode: "form_post",
|
|
1776
|
+
scope: this.config.scopes?.join(" ") ?? "name email",
|
|
1777
|
+
state
|
|
1778
|
+
});
|
|
1779
|
+
return `https://appleid.apple.com/auth/authorize?${params}`;
|
|
1780
|
+
}
|
|
1781
|
+
async exchangeCode(code, _codeVerifier) {
|
|
1782
|
+
const clientSecret = await this.generateClientSecret();
|
|
1783
|
+
const params = new URLSearchParams({
|
|
1784
|
+
client_id: this.config.clientId,
|
|
1785
|
+
client_secret: clientSecret,
|
|
1786
|
+
code,
|
|
1787
|
+
grant_type: "authorization_code",
|
|
1788
|
+
redirect_uri: this.config.redirectUri
|
|
1789
|
+
});
|
|
1790
|
+
const response = await fetch("https://appleid.apple.com/auth/token", {
|
|
1791
|
+
method: "POST",
|
|
1792
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1793
|
+
body: params
|
|
1794
|
+
});
|
|
1795
|
+
if (!response.ok) {
|
|
1796
|
+
const error = await response.text();
|
|
1797
|
+
throw new Error(`Apple token exchange failed: ${error}`);
|
|
1798
|
+
}
|
|
1799
|
+
const data = await response.json();
|
|
1800
|
+
return {
|
|
1801
|
+
accessToken: data["access_token"],
|
|
1802
|
+
refreshToken: data["refresh_token"],
|
|
1803
|
+
expiresIn: data["expires_in"],
|
|
1804
|
+
idToken: data["id_token"],
|
|
1805
|
+
tokenType: data["token_type"]
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
async getUserInfo(accessToken) {
|
|
1809
|
+
return {
|
|
1810
|
+
id: "",
|
|
1811
|
+
email: "",
|
|
1812
|
+
emailVerified: false,
|
|
1813
|
+
raw: { accessToken }
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Parse user info from id_token and form post data
|
|
1818
|
+
* Apple sends user info only on first authorization
|
|
1819
|
+
*/
|
|
1820
|
+
parseUserFromCallback(idToken, userData) {
|
|
1821
|
+
const parts = idToken.split(".");
|
|
1822
|
+
if (parts.length !== 3) {
|
|
1823
|
+
throw new Error("Invalid id_token format");
|
|
1824
|
+
}
|
|
1825
|
+
const payload = JSON.parse(
|
|
1826
|
+
Buffer.from(parts[1], "base64url").toString("utf-8")
|
|
1827
|
+
);
|
|
1828
|
+
const name = userData?.name ? [userData.name.firstName, userData.name.lastName].filter(Boolean).join(" ") : void 0;
|
|
1829
|
+
return {
|
|
1830
|
+
id: payload["sub"],
|
|
1831
|
+
email: userData?.email ?? payload["email"],
|
|
1832
|
+
name,
|
|
1833
|
+
emailVerified: payload["email_verified"] === "true" || payload["email_verified"] === true,
|
|
1834
|
+
raw: payload
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Generate client secret JWT for Apple
|
|
1839
|
+
* Apple requires a JWT signed with your private key
|
|
1840
|
+
*/
|
|
1841
|
+
async generateClientSecret() {
|
|
1842
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1843
|
+
const expiry = now + 86400 * 180;
|
|
1844
|
+
const header = {
|
|
1845
|
+
alg: "ES256",
|
|
1846
|
+
kid: this.config.keyId,
|
|
1847
|
+
typ: "JWT"
|
|
1848
|
+
};
|
|
1849
|
+
const payload = {
|
|
1850
|
+
iss: this.config.teamId,
|
|
1851
|
+
iat: now,
|
|
1852
|
+
exp: expiry,
|
|
1853
|
+
aud: "https://appleid.apple.com",
|
|
1854
|
+
sub: this.config.clientId
|
|
1855
|
+
};
|
|
1856
|
+
const privateKey = await crypto.subtle.importKey(
|
|
1857
|
+
"pkcs8",
|
|
1858
|
+
this.pemToArrayBuffer(this.config.privateKey),
|
|
1859
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
1860
|
+
false,
|
|
1861
|
+
["sign"]
|
|
1862
|
+
);
|
|
1863
|
+
const headerB64 = this.base64UrlEncode(JSON.stringify(header));
|
|
1864
|
+
const payloadB64 = this.base64UrlEncode(JSON.stringify(payload));
|
|
1865
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
1866
|
+
const signature = await crypto.subtle.sign(
|
|
1867
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
1868
|
+
privateKey,
|
|
1869
|
+
new TextEncoder().encode(signingInput)
|
|
1870
|
+
);
|
|
1871
|
+
const signatureB64 = this.base64UrlEncode(
|
|
1872
|
+
String.fromCharCode(...new Uint8Array(signature))
|
|
1873
|
+
);
|
|
1874
|
+
return `${signingInput}.${signatureB64}`;
|
|
1875
|
+
}
|
|
1876
|
+
pemToArrayBuffer(pem) {
|
|
1877
|
+
const pemContents = pem.replace(/-----BEGIN PRIVATE KEY-----/, "").replace(/-----END PRIVATE KEY-----/, "").replace(/\s/g, "");
|
|
1878
|
+
const binaryString = atob(pemContents);
|
|
1879
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
1880
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
1881
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
1882
|
+
}
|
|
1883
|
+
return bytes.buffer;
|
|
1884
|
+
}
|
|
1885
|
+
base64UrlEncode(str) {
|
|
1886
|
+
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
|
|
1890
|
+
// src/providers/oauth/index.ts
|
|
1891
|
+
async function generatePKCE() {
|
|
1892
|
+
const array = new Uint8Array(32);
|
|
1893
|
+
crypto.getRandomValues(array);
|
|
1894
|
+
const codeVerifier = btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1895
|
+
const encoder = new TextEncoder();
|
|
1896
|
+
const data = encoder.encode(codeVerifier);
|
|
1897
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
1898
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1899
|
+
return { codeVerifier, codeChallenge };
|
|
1900
|
+
}
|
|
1901
|
+
function generateState() {
|
|
1902
|
+
const array = new Uint8Array(32);
|
|
1903
|
+
crypto.getRandomValues(array);
|
|
1904
|
+
return btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1905
|
+
}
|
|
1906
|
+
var OAuthManager = class {
|
|
1907
|
+
storage;
|
|
1908
|
+
providers = /* @__PURE__ */ new Map();
|
|
1909
|
+
stateExpirySeconds;
|
|
1910
|
+
constructor(storage, config, options) {
|
|
1911
|
+
this.storage = storage;
|
|
1912
|
+
this.stateExpirySeconds = options?.stateExpirySeconds ?? 600;
|
|
1913
|
+
if (config.google) {
|
|
1914
|
+
this.providers.set("google", new GoogleProvider(config.google));
|
|
1915
|
+
}
|
|
1916
|
+
if (config.github) {
|
|
1917
|
+
this.providers.set("github", new GitHubProvider(config.github));
|
|
1918
|
+
}
|
|
1919
|
+
if (config.microsoft) {
|
|
1920
|
+
this.providers.set("microsoft", new MicrosoftProvider(config.microsoft));
|
|
1921
|
+
}
|
|
1922
|
+
if (config.apple) {
|
|
1923
|
+
this.providers.set("apple", new AppleProvider(config.apple));
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Get available providers
|
|
1928
|
+
*/
|
|
1929
|
+
getAvailableProviders() {
|
|
1930
|
+
return Array.from(this.providers.keys());
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Check if a provider is configured
|
|
1934
|
+
*/
|
|
1935
|
+
hasProvider(name) {
|
|
1936
|
+
return this.providers.has(name);
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Get a provider by name
|
|
1940
|
+
*/
|
|
1941
|
+
getProvider(name) {
|
|
1942
|
+
return this.providers.get(name);
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Start OAuth flow - returns authorization URL
|
|
1946
|
+
*/
|
|
1947
|
+
async startFlow(providerName, options) {
|
|
1948
|
+
const provider = this.providers.get(providerName);
|
|
1949
|
+
if (!provider) {
|
|
1950
|
+
throw new Error(`OAuth provider '${providerName}' not configured`);
|
|
1951
|
+
}
|
|
1952
|
+
const state = generateState();
|
|
1953
|
+
const { codeVerifier, codeChallenge } = await generatePKCE();
|
|
1954
|
+
const stateData = {
|
|
1955
|
+
state,
|
|
1956
|
+
provider: providerName,
|
|
1957
|
+
codeVerifier,
|
|
1958
|
+
tenantId: options?.tenantId,
|
|
1959
|
+
redirectUrl: options?.redirectUrl,
|
|
1960
|
+
expiresAt: new Date(Date.now() + this.stateExpirySeconds * 1e3)
|
|
1961
|
+
};
|
|
1962
|
+
await this.storage.set(
|
|
1963
|
+
`oauth:state:${state}`,
|
|
1964
|
+
stateData,
|
|
1965
|
+
this.stateExpirySeconds
|
|
1966
|
+
);
|
|
1967
|
+
const authorizationUrl = await provider.getAuthorizationUrl(state, codeChallenge);
|
|
1968
|
+
return { authorizationUrl, state };
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Handle OAuth callback
|
|
1972
|
+
*/
|
|
1973
|
+
async handleCallback(providerName, code, state) {
|
|
1974
|
+
const provider = this.providers.get(providerName);
|
|
1975
|
+
if (!provider) {
|
|
1976
|
+
throw new Error(`OAuth provider '${providerName}' not configured`);
|
|
1977
|
+
}
|
|
1978
|
+
const storedState = await this.storage.get(`oauth:state:${state}`);
|
|
1979
|
+
if (!storedState) {
|
|
1980
|
+
throw new Error("Invalid OAuth state");
|
|
1981
|
+
}
|
|
1982
|
+
if (storedState.expiresAt < /* @__PURE__ */ new Date()) {
|
|
1983
|
+
await this.storage.delete(`oauth:state:${state}`);
|
|
1984
|
+
throw new Error("OAuth state expired");
|
|
1985
|
+
}
|
|
1986
|
+
if (storedState.provider !== providerName) {
|
|
1987
|
+
throw new Error("Provider mismatch");
|
|
1988
|
+
}
|
|
1989
|
+
const tokens = await provider.exchangeCode(code, storedState.codeVerifier);
|
|
1990
|
+
const userInfo = await provider.getUserInfo(tokens.accessToken);
|
|
1991
|
+
await this.storage.delete(`oauth:state:${state}`);
|
|
1992
|
+
return {
|
|
1993
|
+
userInfo,
|
|
1994
|
+
tokens,
|
|
1995
|
+
tenantId: storedState.tenantId,
|
|
1996
|
+
redirectUrl: storedState.redirectUrl
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Refresh OAuth tokens (if supported by provider)
|
|
2001
|
+
*/
|
|
2002
|
+
async refreshTokens(providerName, refreshToken) {
|
|
2003
|
+
const provider = this.providers.get(providerName);
|
|
2004
|
+
if (!provider) {
|
|
2005
|
+
throw new Error(`OAuth provider '${providerName}' not configured`);
|
|
2006
|
+
}
|
|
2007
|
+
if (!provider.refreshToken) {
|
|
2008
|
+
throw new Error(`Token refresh not supported for ${providerName}`);
|
|
2009
|
+
}
|
|
2010
|
+
return provider.refreshToken(refreshToken);
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Check if provider supports token refresh
|
|
2014
|
+
*/
|
|
2015
|
+
supportsRefresh(providerName) {
|
|
2016
|
+
const provider = this.providers.get(providerName);
|
|
2017
|
+
return provider ? typeof provider.refreshToken === "function" : false;
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
function createOAuthManager(storage, config, options) {
|
|
2021
|
+
return new OAuthManager(storage, config, options);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// src/providers/magic-link/index.ts
|
|
2025
|
+
var MagicLinkProvider = class {
|
|
2026
|
+
name = "magic-link";
|
|
2027
|
+
type = "magic-link";
|
|
2028
|
+
storage;
|
|
2029
|
+
config;
|
|
2030
|
+
_enabled;
|
|
2031
|
+
constructor(storage, config) {
|
|
2032
|
+
this.storage = storage;
|
|
2033
|
+
this.config = {
|
|
2034
|
+
callbackPath: "/auth/magic-link/callback",
|
|
2035
|
+
expiresIn: 900,
|
|
2036
|
+
// 15 minutes
|
|
2037
|
+
tokenLength: 32,
|
|
2038
|
+
...config
|
|
2039
|
+
};
|
|
2040
|
+
this._enabled = true;
|
|
2041
|
+
}
|
|
2042
|
+
get enabled() {
|
|
2043
|
+
return this._enabled;
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Send magic link to email
|
|
2047
|
+
*/
|
|
2048
|
+
async sendMagicLink(email, options) {
|
|
2049
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
2050
|
+
const token = await generateRandomHex(this.config.tokenLength);
|
|
2051
|
+
const tokenHash = await sha256Hex(token);
|
|
2052
|
+
const expiresAt = new Date(Date.now() + this.config.expiresIn * 1e3);
|
|
2053
|
+
await this.storage.delete(`magic:email:${normalizedEmail}`);
|
|
2054
|
+
const tokenData = {
|
|
2055
|
+
email: normalizedEmail,
|
|
2056
|
+
tokenHash,
|
|
2057
|
+
tenantId: options?.tenantId,
|
|
2058
|
+
redirectUrl: options?.redirectUrl,
|
|
2059
|
+
expiresAt: expiresAt.toISOString()
|
|
2060
|
+
};
|
|
2061
|
+
await this.storage.set(
|
|
2062
|
+
`magic:token:${tokenHash}`,
|
|
2063
|
+
tokenData,
|
|
2064
|
+
this.config.expiresIn
|
|
2065
|
+
);
|
|
2066
|
+
await this.storage.set(
|
|
2067
|
+
`magic:email:${normalizedEmail}`,
|
|
2068
|
+
tokenHash,
|
|
2069
|
+
this.config.expiresIn
|
|
2070
|
+
);
|
|
2071
|
+
const params = new URLSearchParams({ token });
|
|
2072
|
+
if (options?.redirectUrl) {
|
|
2073
|
+
params.set("redirect", options.redirectUrl);
|
|
2074
|
+
}
|
|
2075
|
+
const magicLink = `${this.config.baseUrl}${this.config.callbackPath}?${params}`;
|
|
2076
|
+
try {
|
|
2077
|
+
await this.config.send(normalizedEmail, magicLink, this.config.expiresIn);
|
|
2078
|
+
return { success: true, expiresAt };
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
await this.storage.delete(`magic:token:${tokenHash}`);
|
|
2081
|
+
await this.storage.delete(`magic:email:${normalizedEmail}`);
|
|
2082
|
+
return {
|
|
2083
|
+
success: false,
|
|
2084
|
+
error: error instanceof Error ? error.message : "Failed to send email"
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Verify magic link token
|
|
2090
|
+
*/
|
|
2091
|
+
async verifyMagicLink(token) {
|
|
2092
|
+
const tokenHash = await sha256Hex(token);
|
|
2093
|
+
const tokenData = await this.storage.get(`magic:token:${tokenHash}`);
|
|
2094
|
+
if (!tokenData) {
|
|
2095
|
+
return { success: false, error: "Invalid or expired magic link" };
|
|
2096
|
+
}
|
|
2097
|
+
if (tokenData.usedAt) {
|
|
2098
|
+
return { success: false, error: "Magic link already used" };
|
|
2099
|
+
}
|
|
2100
|
+
if (new Date(tokenData.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
2101
|
+
await this.storage.delete(`magic:token:${tokenHash}`);
|
|
2102
|
+
return { success: false, error: "Magic link expired" };
|
|
2103
|
+
}
|
|
2104
|
+
tokenData.usedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2105
|
+
await this.storage.set(`magic:token:${tokenHash}`, tokenData, 60);
|
|
2106
|
+
await this.storage.delete(`magic:email:${tokenData.email}`);
|
|
2107
|
+
return {
|
|
2108
|
+
success: true,
|
|
2109
|
+
email: tokenData.email,
|
|
2110
|
+
tenantId: tokenData.tenantId,
|
|
2111
|
+
redirectUrl: tokenData.redirectUrl
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Authenticate with magic link token (implements AuthProvider)
|
|
2116
|
+
*/
|
|
2117
|
+
async authenticate(input) {
|
|
2118
|
+
const { credential } = input;
|
|
2119
|
+
if (!credential) {
|
|
2120
|
+
return {
|
|
2121
|
+
success: false,
|
|
2122
|
+
error: "Token is required",
|
|
2123
|
+
errorCode: "INVALID_INPUT"
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
const result = await this.verifyMagicLink(credential);
|
|
2127
|
+
if (!result.success) {
|
|
2128
|
+
return {
|
|
2129
|
+
success: false,
|
|
2130
|
+
error: result.error,
|
|
2131
|
+
errorCode: "INVALID_TOKEN"
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
return {
|
|
2135
|
+
success: true
|
|
2136
|
+
// Auth engine will handle user lookup/creation based on email
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Get provider info
|
|
2141
|
+
*/
|
|
2142
|
+
getInfo() {
|
|
2143
|
+
return {
|
|
2144
|
+
name: this.name,
|
|
2145
|
+
type: this.type,
|
|
2146
|
+
enabled: this.enabled,
|
|
2147
|
+
displayName: "Magic Link"
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Request magic link (for use in auth routes)
|
|
2152
|
+
*/
|
|
2153
|
+
async requestMagicLink(email, options) {
|
|
2154
|
+
return this.sendMagicLink(email, options);
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
function createMagicLinkProvider(storage, config) {
|
|
2158
|
+
return new MagicLinkProvider(storage, config);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/providers/totp/index.ts
|
|
2162
|
+
var DEFAULT_CONFIG = {
|
|
2163
|
+
algorithm: "SHA1",
|
|
2164
|
+
digits: 6,
|
|
2165
|
+
period: 30,
|
|
2166
|
+
window: 1,
|
|
2167
|
+
backupCodeCount: 10
|
|
2168
|
+
};
|
|
2169
|
+
function bytesToBase32(bytes) {
|
|
2170
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
2171
|
+
let result = "";
|
|
2172
|
+
let bits = 0;
|
|
2173
|
+
let value = 0;
|
|
2174
|
+
for (const byte of bytes) {
|
|
2175
|
+
value = value << 8 | byte;
|
|
2176
|
+
bits += 8;
|
|
2177
|
+
while (bits >= 5) {
|
|
2178
|
+
result += alphabet[value >>> bits - 5 & 31];
|
|
2179
|
+
bits -= 5;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
if (bits > 0) {
|
|
2183
|
+
result += alphabet[value << 5 - bits & 31];
|
|
2184
|
+
}
|
|
2185
|
+
return result;
|
|
2186
|
+
}
|
|
2187
|
+
function base32ToBytes(base32) {
|
|
2188
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
2189
|
+
const cleaned = base32.toUpperCase().replace(/[^A-Z2-7]/g, "");
|
|
2190
|
+
const bytes = [];
|
|
2191
|
+
let bits = 0;
|
|
2192
|
+
let value = 0;
|
|
2193
|
+
for (const char of cleaned) {
|
|
2194
|
+
const index = alphabet.indexOf(char);
|
|
2195
|
+
if (index === -1) continue;
|
|
2196
|
+
value = value << 5 | index;
|
|
2197
|
+
bits += 5;
|
|
2198
|
+
if (bits >= 8) {
|
|
2199
|
+
bytes.push(value >>> bits - 8 & 255);
|
|
2200
|
+
bits -= 8;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return new Uint8Array(bytes);
|
|
2204
|
+
}
|
|
2205
|
+
async function hotp(secret, counter, algorithm, digits) {
|
|
2206
|
+
const counterBytes = new Uint8Array(8);
|
|
2207
|
+
const view = new DataView(counterBytes.buffer);
|
|
2208
|
+
view.setBigUint64(0, counter, false);
|
|
2209
|
+
const hashName = algorithm === "SHA1" ? "SHA-1" : `SHA-${algorithm.slice(3)}`;
|
|
2210
|
+
const key = await crypto.subtle.importKey(
|
|
2211
|
+
"raw",
|
|
2212
|
+
secret,
|
|
2213
|
+
{ name: "HMAC", hash: hashName },
|
|
2214
|
+
false,
|
|
2215
|
+
["sign"]
|
|
2216
|
+
);
|
|
2217
|
+
const signature = await crypto.subtle.sign("HMAC", key, counterBytes);
|
|
2218
|
+
const hmac = new Uint8Array(signature);
|
|
2219
|
+
const offset = hmac[hmac.length - 1] & 15;
|
|
2220
|
+
const binary = (hmac[offset] & 127) << 24 | (hmac[offset + 1] & 255) << 16 | (hmac[offset + 2] & 255) << 8 | hmac[offset + 3] & 255;
|
|
2221
|
+
const otp = binary % Math.pow(10, digits);
|
|
2222
|
+
return otp.toString().padStart(digits, "0");
|
|
2223
|
+
}
|
|
2224
|
+
async function generateTOTP(secret, algorithm, digits, period) {
|
|
2225
|
+
const counter = BigInt(Math.floor(Date.now() / 1e3 / period));
|
|
2226
|
+
return hotp(secret, counter, algorithm, digits);
|
|
2227
|
+
}
|
|
2228
|
+
async function verifyTOTP(token, secret, algorithm, digits, period, window) {
|
|
2229
|
+
const now = Math.floor(Date.now() / 1e3 / period);
|
|
2230
|
+
for (let i = -window; i <= window; i++) {
|
|
2231
|
+
const counter = BigInt(now + i);
|
|
2232
|
+
const expected = await hotp(secret, counter, algorithm, digits);
|
|
2233
|
+
if (token === expected) {
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
async function generateBackupCodes(count) {
|
|
2240
|
+
const codes = [];
|
|
2241
|
+
for (let i = 0; i < count; i++) {
|
|
2242
|
+
const hex = await generateRandomHex(4);
|
|
2243
|
+
codes.push(`${hex.slice(0, 4).toUpperCase()}-${hex.slice(4, 8).toUpperCase()}`);
|
|
2244
|
+
}
|
|
2245
|
+
return codes;
|
|
2246
|
+
}
|
|
2247
|
+
async function encryptSecret(secret, keyString) {
|
|
2248
|
+
const encoder = new TextEncoder();
|
|
2249
|
+
const data = encoder.encode(secret);
|
|
2250
|
+
const keyData = encoder.encode(keyString.padEnd(32, "0").slice(0, 32));
|
|
2251
|
+
const key = await crypto.subtle.importKey(
|
|
2252
|
+
"raw",
|
|
2253
|
+
keyData,
|
|
2254
|
+
{ name: "AES-GCM" },
|
|
2255
|
+
false,
|
|
2256
|
+
["encrypt"]
|
|
2257
|
+
);
|
|
2258
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2259
|
+
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
|
|
2260
|
+
const result = new Uint8Array(iv.length + encrypted.byteLength);
|
|
2261
|
+
result.set(iv);
|
|
2262
|
+
result.set(new Uint8Array(encrypted), iv.length);
|
|
2263
|
+
return btoa(String.fromCharCode(...result));
|
|
2264
|
+
}
|
|
2265
|
+
async function decryptSecret(encrypted, keyString) {
|
|
2266
|
+
const encoder = new TextEncoder();
|
|
2267
|
+
const keyData = encoder.encode(keyString.padEnd(32, "0").slice(0, 32));
|
|
2268
|
+
const data = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
|
|
2269
|
+
const iv = data.slice(0, 12);
|
|
2270
|
+
const ciphertext = data.slice(12);
|
|
2271
|
+
const key = await crypto.subtle.importKey(
|
|
2272
|
+
"raw",
|
|
2273
|
+
keyData,
|
|
2274
|
+
{ name: "AES-GCM" },
|
|
2275
|
+
false,
|
|
2276
|
+
["decrypt"]
|
|
2277
|
+
);
|
|
2278
|
+
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
2279
|
+
return new TextDecoder().decode(decrypted);
|
|
2280
|
+
}
|
|
2281
|
+
var TOTPProvider = class {
|
|
2282
|
+
name = "totp";
|
|
2283
|
+
type = "totp";
|
|
2284
|
+
storage;
|
|
2285
|
+
config;
|
|
2286
|
+
_enabled = true;
|
|
2287
|
+
constructor(storage, config) {
|
|
2288
|
+
this.storage = storage;
|
|
2289
|
+
this.config = {
|
|
2290
|
+
...DEFAULT_CONFIG,
|
|
2291
|
+
encryptionKey: "change-me-in-production",
|
|
2292
|
+
...config
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
get enabled() {
|
|
2296
|
+
return this._enabled;
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Setup TOTP for a user
|
|
2300
|
+
* Note: Use setupWithEmail for full setup including QR code URL
|
|
2301
|
+
*/
|
|
2302
|
+
async setup(userId) {
|
|
2303
|
+
return this.setupWithEmail(userId, userId);
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Setup TOTP for a user with email for QR code label
|
|
2307
|
+
*/
|
|
2308
|
+
async setupWithEmail(userId, userEmail) {
|
|
2309
|
+
const secretBytes = crypto.getRandomValues(new Uint8Array(20));
|
|
2310
|
+
const secret = bytesToBase32(secretBytes);
|
|
2311
|
+
const backupCodes = await generateBackupCodes(this.config.backupCodeCount);
|
|
2312
|
+
const otpauthUri = this.createOtpauthUri(secret, userEmail);
|
|
2313
|
+
const qrCodeUrl = `https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=${encodeURIComponent(otpauthUri)}`;
|
|
2314
|
+
const encryptedSecret = await encryptSecret(secret, this.config.encryptionKey);
|
|
2315
|
+
const encryptedBackupCodes = await encryptSecret(
|
|
2316
|
+
JSON.stringify(backupCodes),
|
|
2317
|
+
this.config.encryptionKey
|
|
2318
|
+
);
|
|
2319
|
+
const totpData = {
|
|
2320
|
+
userId,
|
|
2321
|
+
encryptedSecret,
|
|
2322
|
+
backupCodes: encryptedBackupCodes,
|
|
2323
|
+
verified: false,
|
|
2324
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2325
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2326
|
+
};
|
|
2327
|
+
await this.storage.set(`totp:user:${userId}`, totpData);
|
|
2328
|
+
return {
|
|
2329
|
+
secret,
|
|
2330
|
+
qrCode: qrCodeUrl,
|
|
2331
|
+
backupCodes,
|
|
2332
|
+
qrCodeUrl,
|
|
2333
|
+
otpauthUri
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Verify TOTP and activate 2FA (for TwoFactorProvider interface)
|
|
2338
|
+
*/
|
|
2339
|
+
async verifySetup(userId, token) {
|
|
2340
|
+
const totpData = await this.storage.get(`totp:user:${userId}`);
|
|
2341
|
+
if (!totpData) {
|
|
2342
|
+
throw new Error("TOTP not set up for this user");
|
|
2343
|
+
}
|
|
2344
|
+
const secret = await decryptSecret(totpData.encryptedSecret, this.config.encryptionKey);
|
|
2345
|
+
const secretBytes = base32ToBytes(secret);
|
|
2346
|
+
const valid = await verifyTOTP(
|
|
2347
|
+
token,
|
|
2348
|
+
secretBytes,
|
|
2349
|
+
this.config.algorithm,
|
|
2350
|
+
this.config.digits,
|
|
2351
|
+
this.config.period,
|
|
2352
|
+
this.config.window
|
|
2353
|
+
);
|
|
2354
|
+
if (valid) {
|
|
2355
|
+
totpData.verified = true;
|
|
2356
|
+
totpData.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2357
|
+
await this.storage.set(`totp:user:${userId}`, totpData);
|
|
2358
|
+
return true;
|
|
2359
|
+
}
|
|
2360
|
+
return false;
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Verify TOTP during login (for TwoFactorProvider interface)
|
|
2364
|
+
*/
|
|
2365
|
+
async verifyLogin(userId, code) {
|
|
2366
|
+
const result = await this.verifyCode(userId, code);
|
|
2367
|
+
return result.valid;
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Authenticate (for AuthProvider interface)
|
|
2371
|
+
*/
|
|
2372
|
+
async authenticate(_input) {
|
|
2373
|
+
return {
|
|
2374
|
+
success: false,
|
|
2375
|
+
error: "TOTP is used for two-factor authentication, not primary auth",
|
|
2376
|
+
errorCode: "USE_TWO_FACTOR_METHODS"
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Verify TOTP token
|
|
2381
|
+
*/
|
|
2382
|
+
async verifyCode(userId, token) {
|
|
2383
|
+
const totpData = await this.storage.get(`totp:user:${userId}`);
|
|
2384
|
+
if (!totpData || !totpData.verified) {
|
|
2385
|
+
throw new Error("TOTP not enabled for this user");
|
|
2386
|
+
}
|
|
2387
|
+
const secret = await decryptSecret(totpData.encryptedSecret, this.config.encryptionKey);
|
|
2388
|
+
const secretBytes = base32ToBytes(secret);
|
|
2389
|
+
const validTOTP = await verifyTOTP(
|
|
2390
|
+
token,
|
|
2391
|
+
secretBytes,
|
|
2392
|
+
this.config.algorithm,
|
|
2393
|
+
this.config.digits,
|
|
2394
|
+
this.config.period,
|
|
2395
|
+
this.config.window
|
|
2396
|
+
);
|
|
2397
|
+
if (validTOTP) {
|
|
2398
|
+
return { valid: true, usedBackupCode: false };
|
|
2399
|
+
}
|
|
2400
|
+
const backupCodesJson = await decryptSecret(
|
|
2401
|
+
totpData.backupCodes,
|
|
2402
|
+
this.config.encryptionKey
|
|
2403
|
+
);
|
|
2404
|
+
const backupCodes = JSON.parse(backupCodesJson);
|
|
2405
|
+
const normalizedToken = token.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
2406
|
+
const backupIndex = backupCodes.findIndex(
|
|
2407
|
+
(code) => code.replace("-", "") === normalizedToken
|
|
2408
|
+
);
|
|
2409
|
+
if (backupIndex !== -1) {
|
|
2410
|
+
backupCodes.splice(backupIndex, 1);
|
|
2411
|
+
const encryptedBackupCodes = await encryptSecret(
|
|
2412
|
+
JSON.stringify(backupCodes),
|
|
2413
|
+
this.config.encryptionKey
|
|
2414
|
+
);
|
|
2415
|
+
totpData.backupCodes = encryptedBackupCodes;
|
|
2416
|
+
totpData.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2417
|
+
await this.storage.set(`totp:user:${userId}`, totpData);
|
|
2418
|
+
return { valid: true, usedBackupCode: true };
|
|
2419
|
+
}
|
|
2420
|
+
return { valid: false };
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Disable TOTP for user
|
|
2424
|
+
*/
|
|
2425
|
+
async disable(userId) {
|
|
2426
|
+
await this.storage.delete(`totp:user:${userId}`);
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Regenerate backup codes
|
|
2430
|
+
*/
|
|
2431
|
+
async regenerateBackupCodes(userId) {
|
|
2432
|
+
const totpData = await this.storage.get(`totp:user:${userId}`);
|
|
2433
|
+
if (!totpData || !totpData.verified) {
|
|
2434
|
+
throw new Error("TOTP not enabled for this user");
|
|
2435
|
+
}
|
|
2436
|
+
const backupCodes = await generateBackupCodes(this.config.backupCodeCount);
|
|
2437
|
+
const encryptedBackupCodes = await encryptSecret(
|
|
2438
|
+
JSON.stringify(backupCodes),
|
|
2439
|
+
this.config.encryptionKey
|
|
2440
|
+
);
|
|
2441
|
+
totpData.backupCodes = encryptedBackupCodes;
|
|
2442
|
+
totpData.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2443
|
+
await this.storage.set(`totp:user:${userId}`, totpData);
|
|
2444
|
+
return backupCodes;
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Get remaining backup codes count
|
|
2448
|
+
*/
|
|
2449
|
+
async getBackupCodesCount(userId) {
|
|
2450
|
+
const totpData = await this.storage.get(`totp:user:${userId}`);
|
|
2451
|
+
if (!totpData) {
|
|
2452
|
+
return 0;
|
|
2453
|
+
}
|
|
2454
|
+
const backupCodesJson = await decryptSecret(
|
|
2455
|
+
totpData.backupCodes,
|
|
2456
|
+
this.config.encryptionKey
|
|
2457
|
+
);
|
|
2458
|
+
const backupCodes = JSON.parse(backupCodesJson);
|
|
2459
|
+
return backupCodes.length;
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* Check if TOTP is enabled for user
|
|
2463
|
+
*/
|
|
2464
|
+
async isEnabled(userId) {
|
|
2465
|
+
const totpData = await this.storage.get(`totp:user:${userId}`);
|
|
2466
|
+
return !!totpData?.verified;
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Generate current TOTP (for testing)
|
|
2470
|
+
*/
|
|
2471
|
+
async generateCurrent(userId) {
|
|
2472
|
+
const totpData = await this.storage.get(`totp:user:${userId}`);
|
|
2473
|
+
if (!totpData) {
|
|
2474
|
+
throw new Error("TOTP not set up for this user");
|
|
2475
|
+
}
|
|
2476
|
+
const secret = await decryptSecret(totpData.encryptedSecret, this.config.encryptionKey);
|
|
2477
|
+
const secretBytes = base32ToBytes(secret);
|
|
2478
|
+
return generateTOTP(
|
|
2479
|
+
secretBytes,
|
|
2480
|
+
this.config.algorithm,
|
|
2481
|
+
this.config.digits,
|
|
2482
|
+
this.config.period
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Get provider info
|
|
2487
|
+
*/
|
|
2488
|
+
getInfo() {
|
|
2489
|
+
return {
|
|
2490
|
+
name: this.name,
|
|
2491
|
+
type: this.type,
|
|
2492
|
+
enabled: this.enabled,
|
|
2493
|
+
displayName: "Authenticator App"
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Create otpauth URI for QR code
|
|
2498
|
+
*/
|
|
2499
|
+
createOtpauthUri(secret, userEmail) {
|
|
2500
|
+
const params = new URLSearchParams({
|
|
2501
|
+
secret,
|
|
2502
|
+
issuer: this.config.issuer,
|
|
2503
|
+
algorithm: this.config.algorithm,
|
|
2504
|
+
digits: String(this.config.digits),
|
|
2505
|
+
period: String(this.config.period)
|
|
2506
|
+
});
|
|
2507
|
+
const label = encodeURIComponent(`${this.config.issuer}:${userEmail}`);
|
|
2508
|
+
return `otpauth://totp/${label}?${params}`;
|
|
2509
|
+
}
|
|
2510
|
+
};
|
|
2511
|
+
function createTOTPProvider(storage, config) {
|
|
2512
|
+
return new TOTPProvider(storage, config);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// src/providers/webauthn/index.ts
|
|
2516
|
+
function base64UrlEncode2(bytes) {
|
|
2517
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2518
|
+
}
|
|
2519
|
+
function base64UrlDecode2(str) {
|
|
2520
|
+
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
2521
|
+
const padding = "=".repeat((4 - base64.length % 4) % 4);
|
|
2522
|
+
const binary = atob(base64 + padding);
|
|
2523
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
2524
|
+
}
|
|
2525
|
+
function generateChallenge() {
|
|
2526
|
+
const bytes = new Uint8Array(32);
|
|
2527
|
+
crypto.getRandomValues(bytes);
|
|
2528
|
+
return base64UrlEncode2(bytes);
|
|
2529
|
+
}
|
|
2530
|
+
function parseAuthenticatorData(data) {
|
|
2531
|
+
const rpIdHash = data.slice(0, 32);
|
|
2532
|
+
const flagsByte = data[32];
|
|
2533
|
+
const flags = {
|
|
2534
|
+
userPresent: (flagsByte & 1) !== 0,
|
|
2535
|
+
userVerified: (flagsByte & 4) !== 0,
|
|
2536
|
+
attestedCredentialData: (flagsByte & 64) !== 0
|
|
2537
|
+
};
|
|
2538
|
+
const signCount = new DataView(data.buffer, data.byteOffset + 33, 4).getUint32(0, false);
|
|
2539
|
+
let attestedCredentialData;
|
|
2540
|
+
if (flags.attestedCredentialData && data.length > 37) {
|
|
2541
|
+
const aaguid = data.slice(37, 53);
|
|
2542
|
+
const credentialIdLength = new DataView(data.buffer, data.byteOffset + 53, 2).getUint16(0, false);
|
|
2543
|
+
const credentialId = data.slice(55, 55 + credentialIdLength);
|
|
2544
|
+
const publicKey = data.slice(55 + credentialIdLength);
|
|
2545
|
+
attestedCredentialData = { aaguid, credentialId, publicKey };
|
|
2546
|
+
}
|
|
2547
|
+
return { rpIdHash, flags, signCount, attestedCredentialData };
|
|
2548
|
+
}
|
|
2549
|
+
function parseCBORMap(data) {
|
|
2550
|
+
const result = {};
|
|
2551
|
+
let offset = 0;
|
|
2552
|
+
const initial = data[offset++];
|
|
2553
|
+
if ((initial & 224) !== 160) {
|
|
2554
|
+
throw new Error("Expected CBOR map");
|
|
2555
|
+
}
|
|
2556
|
+
const mapSize = initial & 31;
|
|
2557
|
+
for (let i = 0; i < mapSize; i++) {
|
|
2558
|
+
const keyInitial = data[offset++];
|
|
2559
|
+
const keyLength = keyInitial & 31;
|
|
2560
|
+
const key = new TextDecoder().decode(data.slice(offset, offset + keyLength));
|
|
2561
|
+
offset += keyLength;
|
|
2562
|
+
const valueInitial = data[offset++];
|
|
2563
|
+
let valueLength;
|
|
2564
|
+
if ((valueInitial & 31) < 24) {
|
|
2565
|
+
valueLength = valueInitial & 31;
|
|
2566
|
+
} else if ((valueInitial & 31) === 24) {
|
|
2567
|
+
valueLength = data[offset++];
|
|
2568
|
+
} else if ((valueInitial & 31) === 25) {
|
|
2569
|
+
valueLength = data[offset++] << 8 | data[offset++];
|
|
2570
|
+
} else {
|
|
2571
|
+
throw new Error("Unsupported CBOR value length");
|
|
2572
|
+
}
|
|
2573
|
+
result[key] = data.slice(offset, offset + valueLength);
|
|
2574
|
+
offset += valueLength;
|
|
2575
|
+
}
|
|
2576
|
+
return result;
|
|
2577
|
+
}
|
|
2578
|
+
function arrayBufferEqual(a, b) {
|
|
2579
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
2580
|
+
const viewA = new Uint8Array(a);
|
|
2581
|
+
const viewB = new Uint8Array(b);
|
|
2582
|
+
for (let i = 0; i < viewA.length; i++) {
|
|
2583
|
+
if (viewA[i] !== viewB[i]) return false;
|
|
2584
|
+
}
|
|
2585
|
+
return true;
|
|
2586
|
+
}
|
|
2587
|
+
var WebAuthnProvider = class {
|
|
2588
|
+
name = "webauthn";
|
|
2589
|
+
type = "webauthn";
|
|
2590
|
+
storage;
|
|
2591
|
+
config;
|
|
2592
|
+
_enabled = true;
|
|
2593
|
+
constructor(storage, config) {
|
|
2594
|
+
this.storage = storage;
|
|
2595
|
+
this.config = {
|
|
2596
|
+
timeout: 6e4,
|
|
2597
|
+
attestation: "none",
|
|
2598
|
+
userVerification: "preferred",
|
|
2599
|
+
...config
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
get enabled() {
|
|
2603
|
+
return this._enabled;
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* Generate registration options
|
|
2607
|
+
*/
|
|
2608
|
+
async generateRegistrationOptions(userId, userName, userDisplayName, authenticatorType) {
|
|
2609
|
+
const challenge = generateChallenge();
|
|
2610
|
+
const existingCredentials = await this.getUserCredentials(userId);
|
|
2611
|
+
const challengeData = {
|
|
2612
|
+
challenge,
|
|
2613
|
+
userId,
|
|
2614
|
+
type: "registration",
|
|
2615
|
+
expiresAt: new Date(Date.now() + this.config.timeout).toISOString()
|
|
2616
|
+
};
|
|
2617
|
+
await this.storage.set(`webauthn:challenge:${challenge}`, challengeData, this.config.timeout / 1e3);
|
|
2618
|
+
return {
|
|
2619
|
+
challenge,
|
|
2620
|
+
rp: {
|
|
2621
|
+
name: this.config.rpName,
|
|
2622
|
+
id: this.config.rpId
|
|
2623
|
+
},
|
|
2624
|
+
user: {
|
|
2625
|
+
id: base64UrlEncode2(new TextEncoder().encode(userId)),
|
|
2626
|
+
name: userName,
|
|
2627
|
+
displayName: userDisplayName
|
|
2628
|
+
},
|
|
2629
|
+
pubKeyCredParams: [
|
|
2630
|
+
{ type: "public-key", alg: -7 },
|
|
2631
|
+
// ES256
|
|
2632
|
+
{ type: "public-key", alg: -257 }
|
|
2633
|
+
// RS256
|
|
2634
|
+
],
|
|
2635
|
+
timeout: this.config.timeout,
|
|
2636
|
+
attestation: this.config.attestation,
|
|
2637
|
+
authenticatorSelection: {
|
|
2638
|
+
authenticatorAttachment: authenticatorType,
|
|
2639
|
+
residentKey: "preferred",
|
|
2640
|
+
userVerification: this.config.userVerification
|
|
2641
|
+
},
|
|
2642
|
+
excludeCredentials: existingCredentials.map((cred) => ({
|
|
2643
|
+
id: cred.credentialId,
|
|
2644
|
+
type: "public-key",
|
|
2645
|
+
transports: cred.transports
|
|
2646
|
+
}))
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* Verify registration response
|
|
2651
|
+
*/
|
|
2652
|
+
async verifyRegistration(response, challenge, credentialName) {
|
|
2653
|
+
const pending = await this.storage.get(`webauthn:challenge:${challenge}`);
|
|
2654
|
+
if (!pending || new Date(pending.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
2655
|
+
return { success: false, error: "Invalid or expired challenge" };
|
|
2656
|
+
}
|
|
2657
|
+
const userId = pending.userId;
|
|
2658
|
+
if (!userId) {
|
|
2659
|
+
return { success: false, error: "No user ID in challenge" };
|
|
2660
|
+
}
|
|
2661
|
+
const clientDataJSON = base64UrlDecode2(response.response.clientDataJSON);
|
|
2662
|
+
const clientData = JSON.parse(new TextDecoder().decode(clientDataJSON));
|
|
2663
|
+
if (clientData["type"] !== "webauthn.create") {
|
|
2664
|
+
return { success: false, error: "Invalid client data type" };
|
|
2665
|
+
}
|
|
2666
|
+
if (clientData["challenge"] !== challenge) {
|
|
2667
|
+
return { success: false, error: "Challenge mismatch" };
|
|
2668
|
+
}
|
|
2669
|
+
if (clientData["origin"] !== this.config.origin) {
|
|
2670
|
+
return { success: false, error: "Origin mismatch" };
|
|
2671
|
+
}
|
|
2672
|
+
const attestationObject = base64UrlDecode2(response.response.attestationObject);
|
|
2673
|
+
let authData;
|
|
2674
|
+
try {
|
|
2675
|
+
const attestation = parseCBORMap(attestationObject);
|
|
2676
|
+
authData = attestation["authData"];
|
|
2677
|
+
} catch {
|
|
2678
|
+
return { success: false, error: "Failed to parse attestation object" };
|
|
2679
|
+
}
|
|
2680
|
+
const parsedAuthData = parseAuthenticatorData(authData);
|
|
2681
|
+
const expectedRpIdHash = await crypto.subtle.digest(
|
|
2682
|
+
"SHA-256",
|
|
2683
|
+
new TextEncoder().encode(this.config.rpId)
|
|
2684
|
+
);
|
|
2685
|
+
if (!arrayBufferEqual(parsedAuthData.rpIdHash.buffer, expectedRpIdHash)) {
|
|
2686
|
+
return { success: false, error: "RP ID hash mismatch" };
|
|
2687
|
+
}
|
|
2688
|
+
if (!parsedAuthData.flags.userPresent) {
|
|
2689
|
+
return { success: false, error: "User not present" };
|
|
2690
|
+
}
|
|
2691
|
+
if (!parsedAuthData.attestedCredentialData) {
|
|
2692
|
+
return { success: false, error: "No credential data" };
|
|
2693
|
+
}
|
|
2694
|
+
const { credentialId, publicKey } = parsedAuthData.attestedCredentialData;
|
|
2695
|
+
const credentialIdBase64 = base64UrlEncode2(credentialId);
|
|
2696
|
+
const publicKeyBase64 = base64UrlEncode2(publicKey);
|
|
2697
|
+
const existing = await this.storage.get(`webauthn:cred:${credentialIdBase64}`);
|
|
2698
|
+
if (existing) {
|
|
2699
|
+
return { success: false, error: "Credential already registered" };
|
|
2700
|
+
}
|
|
2701
|
+
const credential = {
|
|
2702
|
+
id: crypto.randomUUID(),
|
|
2703
|
+
credentialId: credentialIdBase64,
|
|
2704
|
+
userId,
|
|
2705
|
+
publicKey: publicKeyBase64,
|
|
2706
|
+
counter: parsedAuthData.signCount,
|
|
2707
|
+
transports: response.response.transports ?? [],
|
|
2708
|
+
name: credentialName ?? "Passkey",
|
|
2709
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2710
|
+
};
|
|
2711
|
+
await this.storage.set(`webauthn:cred:${credentialIdBase64}`, credential);
|
|
2712
|
+
const userCredIds = await this.storage.get(`webauthn:user:${userId}`) ?? [];
|
|
2713
|
+
userCredIds.push(credentialIdBase64);
|
|
2714
|
+
await this.storage.set(`webauthn:user:${userId}`, userCredIds);
|
|
2715
|
+
await this.storage.delete(`webauthn:challenge:${challenge}`);
|
|
2716
|
+
return { success: true, credentialId: credentialIdBase64 };
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Generate authentication options
|
|
2720
|
+
*/
|
|
2721
|
+
async generateAuthenticationOptions(userId) {
|
|
2722
|
+
const challenge = generateChallenge();
|
|
2723
|
+
const challengeData = {
|
|
2724
|
+
challenge,
|
|
2725
|
+
userId,
|
|
2726
|
+
type: "authentication",
|
|
2727
|
+
expiresAt: new Date(Date.now() + this.config.timeout).toISOString()
|
|
2728
|
+
};
|
|
2729
|
+
await this.storage.set(`webauthn:challenge:${challenge}`, challengeData, this.config.timeout / 1e3);
|
|
2730
|
+
let allowCredentials = [];
|
|
2731
|
+
if (userId) {
|
|
2732
|
+
const userCredentials = await this.getUserCredentials(userId);
|
|
2733
|
+
allowCredentials = userCredentials.map((cred) => ({
|
|
2734
|
+
id: cred.credentialId,
|
|
2735
|
+
type: "public-key",
|
|
2736
|
+
transports: cred.transports
|
|
2737
|
+
}));
|
|
2738
|
+
}
|
|
2739
|
+
return {
|
|
2740
|
+
challenge,
|
|
2741
|
+
timeout: this.config.timeout,
|
|
2742
|
+
rpId: this.config.rpId,
|
|
2743
|
+
allowCredentials,
|
|
2744
|
+
userVerification: this.config.userVerification
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Verify authentication response
|
|
2749
|
+
*/
|
|
2750
|
+
async verifyAuthentication(response, challenge) {
|
|
2751
|
+
const pending = await this.storage.get(`webauthn:challenge:${challenge}`);
|
|
2752
|
+
if (!pending || new Date(pending.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
2753
|
+
return { success: false, error: "Invalid or expired challenge" };
|
|
2754
|
+
}
|
|
2755
|
+
const credential = await this.storage.get(`webauthn:cred:${response.id}`);
|
|
2756
|
+
if (!credential) {
|
|
2757
|
+
return { success: false, error: "Credential not found" };
|
|
2758
|
+
}
|
|
2759
|
+
const clientDataJSON = base64UrlDecode2(response.response.clientDataJSON);
|
|
2760
|
+
const clientData = JSON.parse(new TextDecoder().decode(clientDataJSON));
|
|
2761
|
+
if (clientData["type"] !== "webauthn.get") {
|
|
2762
|
+
return { success: false, error: "Invalid client data type" };
|
|
2763
|
+
}
|
|
2764
|
+
if (clientData["challenge"] !== challenge) {
|
|
2765
|
+
return { success: false, error: "Challenge mismatch" };
|
|
2766
|
+
}
|
|
2767
|
+
if (clientData["origin"] !== this.config.origin) {
|
|
2768
|
+
return { success: false, error: "Origin mismatch" };
|
|
2769
|
+
}
|
|
2770
|
+
const authenticatorData = base64UrlDecode2(response.response.authenticatorData);
|
|
2771
|
+
const parsedAuthData = parseAuthenticatorData(authenticatorData);
|
|
2772
|
+
const expectedRpIdHash = await crypto.subtle.digest(
|
|
2773
|
+
"SHA-256",
|
|
2774
|
+
new TextEncoder().encode(this.config.rpId)
|
|
2775
|
+
);
|
|
2776
|
+
if (!arrayBufferEqual(parsedAuthData.rpIdHash.buffer, expectedRpIdHash)) {
|
|
2777
|
+
return { success: false, error: "RP ID hash mismatch" };
|
|
2778
|
+
}
|
|
2779
|
+
if (!parsedAuthData.flags.userPresent) {
|
|
2780
|
+
return { success: false, error: "User not present" };
|
|
2781
|
+
}
|
|
2782
|
+
if (parsedAuthData.signCount > 0 && parsedAuthData.signCount <= credential.counter) {
|
|
2783
|
+
return { success: false, error: "Possible credential cloning detected" };
|
|
2784
|
+
}
|
|
2785
|
+
credential.counter = parsedAuthData.signCount;
|
|
2786
|
+
credential.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2787
|
+
await this.storage.set(`webauthn:cred:${response.id}`, credential);
|
|
2788
|
+
await this.storage.delete(`webauthn:challenge:${challenge}`);
|
|
2789
|
+
return {
|
|
2790
|
+
success: true,
|
|
2791
|
+
userId: credential.userId,
|
|
2792
|
+
credentialId: credential.credentialId
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Get user's credentials
|
|
2797
|
+
*/
|
|
2798
|
+
async getUserCredentials(userId) {
|
|
2799
|
+
const credIds = await this.storage.get(`webauthn:user:${userId}`) ?? [];
|
|
2800
|
+
const credentials = [];
|
|
2801
|
+
for (const credId of credIds) {
|
|
2802
|
+
const cred = await this.storage.get(`webauthn:cred:${credId}`);
|
|
2803
|
+
if (cred) {
|
|
2804
|
+
credentials.push(cred);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
return credentials;
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Remove a credential
|
|
2811
|
+
*/
|
|
2812
|
+
async removeCredential(userId, credentialId) {
|
|
2813
|
+
const credential = await this.storage.get(`webauthn:cred:${credentialId}`);
|
|
2814
|
+
if (!credential || credential.userId !== userId) {
|
|
2815
|
+
return false;
|
|
2816
|
+
}
|
|
2817
|
+
await this.storage.delete(`webauthn:cred:${credentialId}`);
|
|
2818
|
+
const credIds = await this.storage.get(`webauthn:user:${userId}`) ?? [];
|
|
2819
|
+
const filtered = credIds.filter((id) => id !== credentialId);
|
|
2820
|
+
await this.storage.set(`webauthn:user:${userId}`, filtered);
|
|
2821
|
+
return true;
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Check if user has any passkeys
|
|
2825
|
+
*/
|
|
2826
|
+
async hasPasskeys(userId) {
|
|
2827
|
+
const credentials = await this.getUserCredentials(userId);
|
|
2828
|
+
return credentials.length > 0;
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Setup (for TwoFactorProvider interface)
|
|
2832
|
+
*/
|
|
2833
|
+
async setup(userId) {
|
|
2834
|
+
const options = await this.generateRegistrationOptions(userId, userId, userId);
|
|
2835
|
+
return {
|
|
2836
|
+
secret: options.challenge,
|
|
2837
|
+
qrCode: "",
|
|
2838
|
+
// Not applicable for WebAuthn
|
|
2839
|
+
backupCodes: [],
|
|
2840
|
+
challenge: options.challenge
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Verify setup (for TwoFactorProvider interface)
|
|
2845
|
+
*/
|
|
2846
|
+
async verifySetup(userId, _code) {
|
|
2847
|
+
const credentials = await this.getUserCredentials(userId);
|
|
2848
|
+
return credentials.length > 0;
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Verify login (for TwoFactorProvider interface)
|
|
2852
|
+
*/
|
|
2853
|
+
async verifyLogin(userId, _code) {
|
|
2854
|
+
const credentials = await this.getUserCredentials(userId);
|
|
2855
|
+
return credentials.length > 0;
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Disable 2FA for user
|
|
2859
|
+
*/
|
|
2860
|
+
async disable(userId) {
|
|
2861
|
+
const credentials = await this.getUserCredentials(userId);
|
|
2862
|
+
for (const cred of credentials) {
|
|
2863
|
+
await this.removeCredential(userId, cred.credentialId);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
/**
|
|
2867
|
+
* Authenticate (for AuthProvider interface)
|
|
2868
|
+
*/
|
|
2869
|
+
async authenticate(_input) {
|
|
2870
|
+
return {
|
|
2871
|
+
success: false,
|
|
2872
|
+
error: "Use generateAuthenticationOptions and verifyAuthentication for WebAuthn authentication",
|
|
2873
|
+
errorCode: "USE_WEBAUTHN_METHODS"
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Get provider info
|
|
2878
|
+
*/
|
|
2879
|
+
getInfo() {
|
|
2880
|
+
return {
|
|
2881
|
+
name: this.name,
|
|
2882
|
+
type: this.type,
|
|
2883
|
+
enabled: this.enabled,
|
|
2884
|
+
displayName: "Passkey"
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
function createWebAuthnProvider(storage, config) {
|
|
2889
|
+
return new WebAuthnProvider(storage, config);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// src/providers/password/index.ts
|
|
2893
|
+
var DEFAULT_CONFIG2 = {
|
|
2894
|
+
minLength: 8,
|
|
2895
|
+
maxLength: 128,
|
|
2896
|
+
requireUppercase: true,
|
|
2897
|
+
requireLowercase: true,
|
|
2898
|
+
requireNumber: true,
|
|
2899
|
+
requireSpecial: false,
|
|
2900
|
+
bcryptCost: 12
|
|
2901
|
+
};
|
|
2902
|
+
var PasswordProvider = class {
|
|
2903
|
+
name = "password";
|
|
2904
|
+
type = "password";
|
|
2905
|
+
storage;
|
|
2906
|
+
config;
|
|
2907
|
+
_enabled;
|
|
2908
|
+
constructor(storage, config) {
|
|
2909
|
+
this.storage = storage;
|
|
2910
|
+
this.config = {
|
|
2911
|
+
...DEFAULT_CONFIG2,
|
|
2912
|
+
...config
|
|
2913
|
+
};
|
|
2914
|
+
this._enabled = false;
|
|
2915
|
+
console.warn(
|
|
2916
|
+
"[Pars Auth] Password provider initialized. Consider using passwordless authentication for better security."
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
get enabled() {
|
|
2920
|
+
return this._enabled;
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Enable the password provider
|
|
2924
|
+
* Must be explicitly called to enable password authentication
|
|
2925
|
+
*/
|
|
2926
|
+
enable() {
|
|
2927
|
+
this._enabled = true;
|
|
2928
|
+
console.warn(
|
|
2929
|
+
"[Pars Auth] Password provider enabled. Ensure you have proper security measures in place (rate limiting, account lockout, etc.)"
|
|
2930
|
+
);
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Disable the password provider
|
|
2934
|
+
*/
|
|
2935
|
+
disable() {
|
|
2936
|
+
this._enabled = false;
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Validate password against policy
|
|
2940
|
+
*/
|
|
2941
|
+
validatePassword(password) {
|
|
2942
|
+
const errors = [];
|
|
2943
|
+
if (password.length < this.config.minLength) {
|
|
2944
|
+
errors.push(`Password must be at least ${this.config.minLength} characters`);
|
|
2945
|
+
}
|
|
2946
|
+
if (password.length > this.config.maxLength) {
|
|
2947
|
+
errors.push(`Password must be at most ${this.config.maxLength} characters`);
|
|
2948
|
+
}
|
|
2949
|
+
if (this.config.requireUppercase && !/[A-Z]/.test(password)) {
|
|
2950
|
+
errors.push("Password must contain at least one uppercase letter");
|
|
2951
|
+
}
|
|
2952
|
+
if (this.config.requireLowercase && !/[a-z]/.test(password)) {
|
|
2953
|
+
errors.push("Password must contain at least one lowercase letter");
|
|
2954
|
+
}
|
|
2955
|
+
if (this.config.requireNumber && !/\d/.test(password)) {
|
|
2956
|
+
errors.push("Password must contain at least one number");
|
|
2957
|
+
}
|
|
2958
|
+
if (this.config.requireSpecial && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
|
2959
|
+
errors.push("Password must contain at least one special character");
|
|
2960
|
+
}
|
|
2961
|
+
return {
|
|
2962
|
+
valid: errors.length === 0,
|
|
2963
|
+
errors
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Check password strength
|
|
2968
|
+
*/
|
|
2969
|
+
checkStrength(password) {
|
|
2970
|
+
let score = 0;
|
|
2971
|
+
if (password.length >= 8) score++;
|
|
2972
|
+
if (password.length >= 12) score++;
|
|
2973
|
+
if (password.length >= 16) score++;
|
|
2974
|
+
if (/[a-z]/.test(password)) score++;
|
|
2975
|
+
if (/[A-Z]/.test(password)) score++;
|
|
2976
|
+
if (/\d/.test(password)) score++;
|
|
2977
|
+
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) score++;
|
|
2978
|
+
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) score++;
|
|
2979
|
+
if (score <= 3) return "weak";
|
|
2980
|
+
if (score <= 5) return "fair";
|
|
2981
|
+
if (score <= 7) return "strong";
|
|
2982
|
+
return "very-strong";
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Hash a password
|
|
2986
|
+
* Uses Web Crypto API for PBKDF2 (bcrypt alternative for edge runtime)
|
|
2987
|
+
*/
|
|
2988
|
+
async hashPassword(password) {
|
|
2989
|
+
if (this.config.hashPassword) {
|
|
2990
|
+
return this.config.hashPassword(password);
|
|
2991
|
+
}
|
|
2992
|
+
const encoder = new TextEncoder();
|
|
2993
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
2994
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
2995
|
+
"raw",
|
|
2996
|
+
encoder.encode(password),
|
|
2997
|
+
"PBKDF2",
|
|
2998
|
+
false,
|
|
2999
|
+
["deriveBits"]
|
|
3000
|
+
);
|
|
3001
|
+
const iterations = 1e5 * (this.config.bcryptCost / 10);
|
|
3002
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
3003
|
+
{
|
|
3004
|
+
name: "PBKDF2",
|
|
3005
|
+
salt,
|
|
3006
|
+
iterations,
|
|
3007
|
+
hash: "SHA-256"
|
|
3008
|
+
},
|
|
3009
|
+
keyMaterial,
|
|
3010
|
+
256
|
|
3011
|
+
);
|
|
3012
|
+
const hashArray = new Uint8Array(derivedBits);
|
|
3013
|
+
const saltB64 = btoa(String.fromCharCode(...salt));
|
|
3014
|
+
const hashB64 = btoa(String.fromCharCode(...hashArray));
|
|
3015
|
+
return `$pbkdf2-sha256$${iterations}$${saltB64}$${hashB64}`;
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* Verify a password against a hash
|
|
3019
|
+
*/
|
|
3020
|
+
async verifyPassword(password, hash) {
|
|
3021
|
+
if (this.config.verifyPassword) {
|
|
3022
|
+
return this.config.verifyPassword(password, hash);
|
|
3023
|
+
}
|
|
3024
|
+
const parts = hash.split("$");
|
|
3025
|
+
if (parts.length !== 5 || parts[1] !== "pbkdf2-sha256") {
|
|
3026
|
+
return false;
|
|
3027
|
+
}
|
|
3028
|
+
const iterations = parseInt(parts[2], 10);
|
|
3029
|
+
const salt = Uint8Array.from(atob(parts[3]), (c) => c.charCodeAt(0));
|
|
3030
|
+
const storedHash = Uint8Array.from(atob(parts[4]), (c) => c.charCodeAt(0));
|
|
3031
|
+
const encoder = new TextEncoder();
|
|
3032
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
3033
|
+
"raw",
|
|
3034
|
+
encoder.encode(password),
|
|
3035
|
+
"PBKDF2",
|
|
3036
|
+
false,
|
|
3037
|
+
["deriveBits"]
|
|
3038
|
+
);
|
|
3039
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
3040
|
+
{
|
|
3041
|
+
name: "PBKDF2",
|
|
3042
|
+
salt,
|
|
3043
|
+
iterations,
|
|
3044
|
+
hash: "SHA-256"
|
|
3045
|
+
},
|
|
3046
|
+
keyMaterial,
|
|
3047
|
+
256
|
|
3048
|
+
);
|
|
3049
|
+
const computedHash = new Uint8Array(derivedBits);
|
|
3050
|
+
return this.constantTimeEquals(storedHash, computedHash);
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Authenticate with password (implements AuthProvider)
|
|
3054
|
+
*/
|
|
3055
|
+
async authenticate(input) {
|
|
3056
|
+
if (!this._enabled) {
|
|
3057
|
+
return {
|
|
3058
|
+
success: false,
|
|
3059
|
+
error: "Password authentication is disabled",
|
|
3060
|
+
errorCode: "PROVIDER_DISABLED"
|
|
3061
|
+
};
|
|
3062
|
+
}
|
|
3063
|
+
const { identifier, credential } = input;
|
|
3064
|
+
if (!identifier || !credential) {
|
|
3065
|
+
return {
|
|
3066
|
+
success: false,
|
|
3067
|
+
error: "Email and password are required",
|
|
3068
|
+
errorCode: "INVALID_INPUT"
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
const validation = this.validatePassword(credential);
|
|
3072
|
+
if (!validation.valid) {
|
|
3073
|
+
return {
|
|
3074
|
+
success: false,
|
|
3075
|
+
error: validation.errors.join(", "),
|
|
3076
|
+
errorCode: "INVALID_PASSWORD"
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
return {
|
|
3080
|
+
success: true
|
|
3081
|
+
// Auth engine will handle user lookup and password verification
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Store password hash for a user
|
|
3086
|
+
*/
|
|
3087
|
+
async setPassword(userId, password) {
|
|
3088
|
+
const validation = this.validatePassword(password);
|
|
3089
|
+
if (!validation.valid) {
|
|
3090
|
+
return { success: false, errors: validation.errors };
|
|
3091
|
+
}
|
|
3092
|
+
const hash = await this.hashPassword(password);
|
|
3093
|
+
await this.storage.set(`password:user:${userId}`, {
|
|
3094
|
+
hash,
|
|
3095
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3096
|
+
});
|
|
3097
|
+
return { success: true };
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Verify password for a user
|
|
3101
|
+
*/
|
|
3102
|
+
async verifyUserPassword(userId, password) {
|
|
3103
|
+
const data = await this.storage.get(`password:user:${userId}`);
|
|
3104
|
+
if (!data) {
|
|
3105
|
+
return false;
|
|
3106
|
+
}
|
|
3107
|
+
return this.verifyPassword(password, data.hash);
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Check if user has password set
|
|
3111
|
+
*/
|
|
3112
|
+
async hasPassword(userId) {
|
|
3113
|
+
return this.storage.has(`password:user:${userId}`);
|
|
3114
|
+
}
|
|
3115
|
+
/**
|
|
3116
|
+
* Remove password for a user (switch to passwordless)
|
|
3117
|
+
*/
|
|
3118
|
+
async removePassword(userId) {
|
|
3119
|
+
await this.storage.delete(`password:user:${userId}`);
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* Get provider info
|
|
3123
|
+
*/
|
|
3124
|
+
getInfo() {
|
|
3125
|
+
return {
|
|
3126
|
+
name: this.name,
|
|
3127
|
+
type: this.type,
|
|
3128
|
+
enabled: this.enabled,
|
|
3129
|
+
displayName: "Password"
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
/**
|
|
3133
|
+
* Constant-time comparison to prevent timing attacks
|
|
3134
|
+
*/
|
|
3135
|
+
constantTimeEquals(a, b) {
|
|
3136
|
+
if (a.length !== b.length) {
|
|
3137
|
+
return false;
|
|
3138
|
+
}
|
|
3139
|
+
let result = 0;
|
|
3140
|
+
for (let i = 0; i < a.length; i++) {
|
|
3141
|
+
result |= a[i] ^ b[i];
|
|
3142
|
+
}
|
|
3143
|
+
return result === 0;
|
|
3144
|
+
}
|
|
3145
|
+
};
|
|
3146
|
+
function createPasswordProvider(storage, config) {
|
|
3147
|
+
return new PasswordProvider(storage, config);
|
|
3148
|
+
}
|
|
3149
|
+
function toAdapterUser(user, authMethod) {
|
|
3150
|
+
return {
|
|
3151
|
+
id: user.id,
|
|
3152
|
+
email: authMethod?.provider === "email" ? authMethod.providerId : null,
|
|
3153
|
+
phone: authMethod?.provider === "phone" ? authMethod.providerId : null,
|
|
3154
|
+
name: user.displayName ?? null,
|
|
3155
|
+
avatar: user.avatarUrl ?? null,
|
|
3156
|
+
emailVerified: user.emailVerified,
|
|
3157
|
+
phoneVerified: user.phoneVerified,
|
|
3158
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
3159
|
+
status: user.status,
|
|
3160
|
+
createdAt: user.insertedAt,
|
|
3161
|
+
updatedAt: user.updatedAt
|
|
3162
|
+
};
|
|
3163
|
+
}
|
|
3164
|
+
function toAdapterSession(session) {
|
|
3165
|
+
return {
|
|
3166
|
+
id: session.id,
|
|
3167
|
+
userId: session.userId,
|
|
3168
|
+
tenantId: session.currentTenantId ?? null,
|
|
3169
|
+
expiresAt: session.expiresAt,
|
|
3170
|
+
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
|
3171
|
+
deviceType: session.deviceType ?? null,
|
|
3172
|
+
deviceName: session.deviceName ?? null,
|
|
3173
|
+
userAgent: session.userAgent ?? null,
|
|
3174
|
+
ipAddress: session.ipAddress ?? null,
|
|
3175
|
+
status: session.status,
|
|
3176
|
+
createdAt: session.insertedAt,
|
|
3177
|
+
updatedAt: session.updatedAt
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
function toAdapterAuthMethod(method) {
|
|
3181
|
+
return {
|
|
3182
|
+
id: method.id,
|
|
3183
|
+
userId: method.userId,
|
|
3184
|
+
provider: method.provider,
|
|
3185
|
+
providerId: method.providerId,
|
|
3186
|
+
verified: method.verified,
|
|
3187
|
+
metadata: method.metadata ?? void 0,
|
|
3188
|
+
createdAt: method.insertedAt,
|
|
3189
|
+
updatedAt: method.updatedAt
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
function toAdapterTenant(tenant) {
|
|
3193
|
+
return {
|
|
3194
|
+
id: tenant.id,
|
|
3195
|
+
name: tenant.name,
|
|
3196
|
+
slug: tenant.slug,
|
|
3197
|
+
status: tenant.status,
|
|
3198
|
+
createdAt: tenant.insertedAt,
|
|
3199
|
+
updatedAt: tenant.updatedAt
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
function toAdapterMembership(membership, roleName) {
|
|
3203
|
+
return {
|
|
3204
|
+
id: membership.id,
|
|
3205
|
+
userId: membership.userId,
|
|
3206
|
+
tenantId: membership.tenantId,
|
|
3207
|
+
role: roleName ?? "member",
|
|
3208
|
+
permissions: membership.permissions ? Object.keys(membership.permissions) : void 0,
|
|
3209
|
+
status: membership.status,
|
|
3210
|
+
createdAt: membership.insertedAt,
|
|
3211
|
+
updatedAt: membership.updatedAt
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
function createDrizzleAdapter(config) {
|
|
3215
|
+
const { db, schema, softDelete = true } = config;
|
|
3216
|
+
const { users, sessions, authMethods, tenants, tenantMemberships, roles } = schema;
|
|
3217
|
+
return {
|
|
3218
|
+
// ============================================
|
|
3219
|
+
// User Operations
|
|
3220
|
+
// ============================================
|
|
3221
|
+
async createUser(input) {
|
|
3222
|
+
const [user] = await db.insert(users).values({
|
|
3223
|
+
displayName: input.name,
|
|
3224
|
+
avatarUrl: input.avatar,
|
|
3225
|
+
emailVerified: input.emailVerified ?? false,
|
|
3226
|
+
phoneVerified: input.phoneVerified ?? false,
|
|
3227
|
+
twoFactorEnabled: false,
|
|
3228
|
+
status: "active",
|
|
3229
|
+
metadata: {}
|
|
3230
|
+
}).returning();
|
|
3231
|
+
let authMethod;
|
|
3232
|
+
if (input.email) {
|
|
3233
|
+
[authMethod] = await db.insert(authMethods).values({
|
|
3234
|
+
userId: user.id,
|
|
3235
|
+
provider: "email",
|
|
3236
|
+
providerId: input.email.toLowerCase(),
|
|
3237
|
+
verified: input.emailVerified ?? false
|
|
3238
|
+
}).returning();
|
|
3239
|
+
} else if (input.phone) {
|
|
3240
|
+
[authMethod] = await db.insert(authMethods).values({
|
|
3241
|
+
userId: user.id,
|
|
3242
|
+
provider: "phone",
|
|
3243
|
+
providerId: input.phone,
|
|
3244
|
+
verified: input.phoneVerified ?? false
|
|
3245
|
+
}).returning();
|
|
3246
|
+
}
|
|
3247
|
+
return toAdapterUser(user, authMethod);
|
|
3248
|
+
},
|
|
3249
|
+
async findUserById(id) {
|
|
3250
|
+
const [result] = await db.select().from(users).where(and(eq(users.id, id), softDelete ? isNull(users.deletedAt) : void 0)).limit(1);
|
|
3251
|
+
if (!result) return null;
|
|
3252
|
+
const [authMethod] = await db.select().from(authMethods).where(and(eq(authMethods.userId, id), isNull(authMethods.deletedAt))).limit(1);
|
|
3253
|
+
return toAdapterUser(result, authMethod);
|
|
3254
|
+
},
|
|
3255
|
+
async findUserByEmail(email) {
|
|
3256
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
3257
|
+
const [result] = await db.select({
|
|
3258
|
+
user: users,
|
|
3259
|
+
authMethod: authMethods
|
|
3260
|
+
}).from(authMethods).innerJoin(users, eq(authMethods.userId, users.id)).where(
|
|
3261
|
+
and(
|
|
3262
|
+
eq(authMethods.provider, "email"),
|
|
3263
|
+
eq(authMethods.providerId, normalizedEmail),
|
|
3264
|
+
softDelete ? isNull(users.deletedAt) : void 0
|
|
3265
|
+
)
|
|
3266
|
+
).limit(1);
|
|
3267
|
+
if (!result) return null;
|
|
3268
|
+
return toAdapterUser(result.user, result.authMethod);
|
|
3269
|
+
},
|
|
3270
|
+
async findUserByPhone(phone) {
|
|
3271
|
+
const [result] = await db.select({
|
|
3272
|
+
user: users,
|
|
3273
|
+
authMethod: authMethods
|
|
3274
|
+
}).from(authMethods).innerJoin(users, eq(authMethods.userId, users.id)).where(
|
|
3275
|
+
and(
|
|
3276
|
+
eq(authMethods.provider, "phone"),
|
|
3277
|
+
eq(authMethods.providerId, phone),
|
|
3278
|
+
softDelete ? isNull(users.deletedAt) : void 0
|
|
3279
|
+
)
|
|
3280
|
+
).limit(1);
|
|
3281
|
+
if (!result) return null;
|
|
3282
|
+
return toAdapterUser(result.user, result.authMethod);
|
|
3283
|
+
},
|
|
3284
|
+
async updateUser(id, data) {
|
|
3285
|
+
const updateData = {
|
|
3286
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3287
|
+
};
|
|
3288
|
+
if (data.name !== void 0) updateData["displayName"] = data.name;
|
|
3289
|
+
if (data.avatar !== void 0) updateData["avatarUrl"] = data.avatar;
|
|
3290
|
+
if (data.emailVerified !== void 0) updateData["emailVerified"] = data.emailVerified;
|
|
3291
|
+
if (data.phoneVerified !== void 0) updateData["phoneVerified"] = data.phoneVerified;
|
|
3292
|
+
if (data.twoFactorEnabled !== void 0) updateData["twoFactorEnabled"] = data.twoFactorEnabled;
|
|
3293
|
+
if (data.status !== void 0) updateData["status"] = data.status;
|
|
3294
|
+
const [user] = await db.update(users).set(updateData).where(eq(users.id, id)).returning();
|
|
3295
|
+
const [authMethod] = await db.select().from(authMethods).where(eq(authMethods.userId, id)).limit(1);
|
|
3296
|
+
return toAdapterUser(user, authMethod);
|
|
3297
|
+
},
|
|
3298
|
+
async deleteUser(id) {
|
|
3299
|
+
if (softDelete) {
|
|
3300
|
+
await db.update(users).set({ deletedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, id));
|
|
3301
|
+
} else {
|
|
3302
|
+
await db.delete(users).where(eq(users.id, id));
|
|
3303
|
+
}
|
|
3304
|
+
},
|
|
3305
|
+
// ============================================
|
|
3306
|
+
// Session Operations
|
|
3307
|
+
// ============================================
|
|
3308
|
+
async createSession(input) {
|
|
3309
|
+
const [session] = await db.insert(sessions).values({
|
|
3310
|
+
userId: input.userId,
|
|
3311
|
+
currentTenantId: input.tenantId,
|
|
3312
|
+
expiresAt: input.expiresAt,
|
|
3313
|
+
refreshExpiresAt: input.refreshExpiresAt,
|
|
3314
|
+
deviceType: input.deviceType,
|
|
3315
|
+
deviceName: input.deviceName,
|
|
3316
|
+
userAgent: input.userAgent,
|
|
3317
|
+
ipAddress: input.ipAddress,
|
|
3318
|
+
status: "active",
|
|
3319
|
+
csrfTokenHash: "",
|
|
3320
|
+
lastActivityAt: /* @__PURE__ */ new Date()
|
|
3321
|
+
}).returning();
|
|
3322
|
+
return toAdapterSession(session);
|
|
3323
|
+
},
|
|
3324
|
+
async findSessionById(id) {
|
|
3325
|
+
const [session] = await db.select().from(sessions).where(and(eq(sessions.id, id), eq(sessions.status, "active"))).limit(1);
|
|
3326
|
+
if (!session) return null;
|
|
3327
|
+
return toAdapterSession(session);
|
|
3328
|
+
},
|
|
3329
|
+
async findSessionsByUserId(userId) {
|
|
3330
|
+
const result = await db.select().from(sessions).where(and(eq(sessions.userId, userId), eq(sessions.status, "active"))).orderBy(desc(sessions.lastActivityAt));
|
|
3331
|
+
return result.map(toAdapterSession);
|
|
3332
|
+
},
|
|
3333
|
+
async updateSession(id, data) {
|
|
3334
|
+
const updateData = {
|
|
3335
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3336
|
+
};
|
|
3337
|
+
if (data.tenantId !== void 0) updateData["currentTenantId"] = data.tenantId;
|
|
3338
|
+
if (data.expiresAt !== void 0) updateData["expiresAt"] = data.expiresAt;
|
|
3339
|
+
if (data.refreshExpiresAt !== void 0) updateData["refreshExpiresAt"] = data.refreshExpiresAt;
|
|
3340
|
+
if (data.status !== void 0) updateData["status"] = data.status;
|
|
3341
|
+
const [session] = await db.update(sessions).set(updateData).where(eq(sessions.id, id)).returning();
|
|
3342
|
+
return toAdapterSession(session);
|
|
3343
|
+
},
|
|
3344
|
+
async deleteSession(id) {
|
|
3345
|
+
await db.update(sessions).set({ status: "revoked", revokedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id));
|
|
3346
|
+
},
|
|
3347
|
+
async deleteSessionsByUserId(userId) {
|
|
3348
|
+
await db.update(sessions).set({ status: "revoked", revokedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.userId, userId));
|
|
3349
|
+
},
|
|
3350
|
+
// ============================================
|
|
3351
|
+
// Auth Method Operations
|
|
3352
|
+
// ============================================
|
|
3353
|
+
async createAuthMethod(input) {
|
|
3354
|
+
const [method] = await db.insert(authMethods).values({
|
|
3355
|
+
userId: input.userId,
|
|
3356
|
+
provider: input.provider,
|
|
3357
|
+
providerId: input.providerId,
|
|
3358
|
+
verified: input.verified ?? false,
|
|
3359
|
+
metadata: input.metadata ?? {}
|
|
3360
|
+
}).returning();
|
|
3361
|
+
return toAdapterAuthMethod(method);
|
|
3362
|
+
},
|
|
3363
|
+
async findAuthMethod(provider, providerId) {
|
|
3364
|
+
const [method] = await db.select().from(authMethods).where(
|
|
3365
|
+
and(
|
|
3366
|
+
eq(authMethods.provider, provider),
|
|
3367
|
+
eq(authMethods.providerId, providerId),
|
|
3368
|
+
softDelete ? isNull(authMethods.deletedAt) : void 0
|
|
3369
|
+
)
|
|
3370
|
+
).limit(1);
|
|
3371
|
+
if (!method) return null;
|
|
3372
|
+
return toAdapterAuthMethod(method);
|
|
3373
|
+
},
|
|
3374
|
+
async findAuthMethodsByUserId(userId) {
|
|
3375
|
+
const result = await db.select().from(authMethods).where(
|
|
3376
|
+
and(eq(authMethods.userId, userId), softDelete ? isNull(authMethods.deletedAt) : void 0)
|
|
3377
|
+
);
|
|
3378
|
+
return result.map(toAdapterAuthMethod);
|
|
3379
|
+
},
|
|
3380
|
+
async deleteAuthMethod(id) {
|
|
3381
|
+
if (softDelete) {
|
|
3382
|
+
await db.update(authMethods).set({ deletedAt: /* @__PURE__ */ new Date() }).where(eq(authMethods.id, id));
|
|
3383
|
+
} else {
|
|
3384
|
+
await db.delete(authMethods).where(eq(authMethods.id, id));
|
|
3385
|
+
}
|
|
3386
|
+
},
|
|
3387
|
+
// ============================================
|
|
3388
|
+
// Tenant Operations (Optional)
|
|
3389
|
+
// ============================================
|
|
3390
|
+
async findTenantById(id) {
|
|
3391
|
+
if (!tenants) return null;
|
|
3392
|
+
const [tenant] = await db.select().from(tenants).where(and(eq(tenants.id, id), softDelete ? isNull(tenants.deletedAt) : void 0)).limit(1);
|
|
3393
|
+
if (!tenant) return null;
|
|
3394
|
+
return toAdapterTenant(tenant);
|
|
3395
|
+
},
|
|
3396
|
+
async findTenantBySlug(slug) {
|
|
3397
|
+
if (!tenants) return null;
|
|
3398
|
+
const [tenant] = await db.select().from(tenants).where(and(eq(tenants.slug, slug), softDelete ? isNull(tenants.deletedAt) : void 0)).limit(1);
|
|
3399
|
+
if (!tenant) return null;
|
|
3400
|
+
return toAdapterTenant(tenant);
|
|
3401
|
+
},
|
|
3402
|
+
// ============================================
|
|
3403
|
+
// Membership Operations (Optional)
|
|
3404
|
+
// ============================================
|
|
3405
|
+
async findMembership(userId, tenantId) {
|
|
3406
|
+
if (!tenantMemberships) return null;
|
|
3407
|
+
const [membership] = await db.select().from(tenantMemberships).where(
|
|
3408
|
+
and(
|
|
3409
|
+
eq(tenantMemberships.userId, userId),
|
|
3410
|
+
eq(tenantMemberships.tenantId, tenantId),
|
|
3411
|
+
softDelete ? isNull(tenantMemberships.deletedAt) : void 0
|
|
3412
|
+
)
|
|
3413
|
+
).limit(1);
|
|
3414
|
+
if (!membership) return null;
|
|
3415
|
+
let roleName;
|
|
3416
|
+
if (roles && membership.roleId) {
|
|
3417
|
+
const [role] = await db.select().from(roles).where(eq(roles.id, membership.roleId)).limit(1);
|
|
3418
|
+
roleName = role?.name;
|
|
3419
|
+
}
|
|
3420
|
+
return toAdapterMembership(membership, roleName);
|
|
3421
|
+
},
|
|
3422
|
+
async findMembershipsByUserId(userId) {
|
|
3423
|
+
if (!tenantMemberships) return [];
|
|
3424
|
+
const result = await db.select().from(tenantMemberships).where(
|
|
3425
|
+
and(
|
|
3426
|
+
eq(tenantMemberships.userId, userId),
|
|
3427
|
+
eq(tenantMemberships.status, "active"),
|
|
3428
|
+
softDelete ? isNull(tenantMemberships.deletedAt) : void 0
|
|
3429
|
+
)
|
|
3430
|
+
);
|
|
3431
|
+
const membershipsWithRoles = await Promise.all(
|
|
3432
|
+
result.map(async (membership) => {
|
|
3433
|
+
let roleName;
|
|
3434
|
+
if (roles && membership.roleId) {
|
|
3435
|
+
const [role] = await db.select().from(roles).where(eq(roles.id, membership.roleId)).limit(1);
|
|
3436
|
+
roleName = role?.name;
|
|
3437
|
+
}
|
|
3438
|
+
return toAdapterMembership(membership, roleName);
|
|
3439
|
+
})
|
|
3440
|
+
);
|
|
3441
|
+
return membershipsWithRoles;
|
|
3442
|
+
},
|
|
3443
|
+
async createMembership(input) {
|
|
3444
|
+
if (!tenantMemberships) {
|
|
3445
|
+
throw new Error("Tenant memberships table not configured");
|
|
3446
|
+
}
|
|
3447
|
+
let roleId;
|
|
3448
|
+
if (roles && input.role) {
|
|
3449
|
+
const [role] = await db.select().from(roles).where(eq(roles.name, input.role)).limit(1);
|
|
3450
|
+
roleId = role?.id;
|
|
3451
|
+
}
|
|
3452
|
+
const [membership] = await db.insert(tenantMemberships).values({
|
|
3453
|
+
userId: input.userId,
|
|
3454
|
+
tenantId: input.tenantId,
|
|
3455
|
+
roleId: roleId ?? null,
|
|
3456
|
+
status: "active",
|
|
3457
|
+
permissions: input.permissions ? Object.fromEntries(input.permissions.map((p) => [p, true])) : {},
|
|
3458
|
+
accessLevel: "full",
|
|
3459
|
+
resourceRestrictions: {},
|
|
3460
|
+
joinedAt: /* @__PURE__ */ new Date()
|
|
3461
|
+
}).returning();
|
|
3462
|
+
return toAdapterMembership(membership, input.role);
|
|
3463
|
+
},
|
|
3464
|
+
async updateMembership(id, data) {
|
|
3465
|
+
if (!tenantMemberships) {
|
|
3466
|
+
throw new Error("Tenant memberships table not configured");
|
|
3467
|
+
}
|
|
3468
|
+
const updateData = {
|
|
3469
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3470
|
+
};
|
|
3471
|
+
if (data.role !== void 0 && roles) {
|
|
3472
|
+
const [role] = await db.select().from(roles).where(eq(roles.name, data.role)).limit(1);
|
|
3473
|
+
updateData["roleId"] = role?.id ?? null;
|
|
3474
|
+
}
|
|
3475
|
+
if (data.status !== void 0) updateData["status"] = data.status;
|
|
3476
|
+
if (data.permissions !== void 0) {
|
|
3477
|
+
updateData["permissions"] = Object.fromEntries(data.permissions.map((p) => [p, true]));
|
|
3478
|
+
}
|
|
3479
|
+
const [membership] = await db.update(tenantMemberships).set(updateData).where(eq(tenantMemberships.id, id)).returning();
|
|
3480
|
+
let roleName;
|
|
3481
|
+
if (roles && membership.roleId) {
|
|
3482
|
+
const [role] = await db.select().from(roles).where(eq(roles.id, membership.roleId)).limit(1);
|
|
3483
|
+
roleName = role?.name;
|
|
3484
|
+
}
|
|
3485
|
+
return toAdapterMembership(membership, roleName);
|
|
3486
|
+
},
|
|
3487
|
+
async deleteMembership(id) {
|
|
3488
|
+
if (!tenantMemberships) return;
|
|
3489
|
+
if (softDelete) {
|
|
3490
|
+
await db.update(tenantMemberships).set({ deletedAt: /* @__PURE__ */ new Date(), status: "inactive" }).where(eq(tenantMemberships.id, id));
|
|
3491
|
+
} else {
|
|
3492
|
+
await db.delete(tenantMemberships).where(eq(tenantMemberships.id, id));
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
};
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
// src/services/email/resend-provider.ts
|
|
3499
|
+
var ResendEmailProvider = class {
|
|
3500
|
+
apiKey;
|
|
3501
|
+
fromEmail;
|
|
3502
|
+
fromName;
|
|
3503
|
+
baseUrl;
|
|
3504
|
+
constructor(config) {
|
|
3505
|
+
this.apiKey = config.apiKey;
|
|
3506
|
+
this.fromEmail = config.fromEmail ?? "noreply@example.com";
|
|
3507
|
+
this.fromName = config.fromName ?? "App";
|
|
3508
|
+
this.baseUrl = config.baseUrl ?? "https://api.resend.com";
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* Send email via Resend API
|
|
3512
|
+
*/
|
|
3513
|
+
async sendEmail(options) {
|
|
3514
|
+
const from = options.from ?? `${this.fromName} <${this.fromEmail}>`;
|
|
3515
|
+
try {
|
|
3516
|
+
const response = await fetch(`${this.baseUrl}/emails`, {
|
|
3517
|
+
method: "POST",
|
|
3518
|
+
headers: {
|
|
3519
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
3520
|
+
"Content-Type": "application/json"
|
|
3521
|
+
},
|
|
3522
|
+
body: JSON.stringify({
|
|
3523
|
+
from,
|
|
3524
|
+
to: [options.to],
|
|
3525
|
+
subject: options.subject,
|
|
3526
|
+
html: options.html,
|
|
3527
|
+
text: options.text,
|
|
3528
|
+
reply_to: options.replyTo,
|
|
3529
|
+
cc: options.cc,
|
|
3530
|
+
bcc: options.bcc,
|
|
3531
|
+
headers: options.headers,
|
|
3532
|
+
attachments: options.attachments?.map((a) => ({
|
|
3533
|
+
filename: a.filename,
|
|
3534
|
+
content: typeof a.content === "string" ? a.content : a.content.toString("base64"),
|
|
3535
|
+
content_type: a.contentType
|
|
3536
|
+
}))
|
|
3537
|
+
})
|
|
3538
|
+
});
|
|
3539
|
+
if (!response.ok) {
|
|
3540
|
+
const errorData = await response.json().catch(() => ({}));
|
|
3541
|
+
return {
|
|
3542
|
+
success: false,
|
|
3543
|
+
error: errorData["message"] ?? `HTTP ${response.status}: ${response.statusText}`
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
const data = await response.json();
|
|
3547
|
+
return {
|
|
3548
|
+
success: true,
|
|
3549
|
+
messageId: data["id"]
|
|
3550
|
+
};
|
|
3551
|
+
} catch (error) {
|
|
3552
|
+
return {
|
|
3553
|
+
success: false,
|
|
3554
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
};
|
|
3559
|
+
function createResendProvider(config) {
|
|
3560
|
+
return new ResendEmailProvider(config);
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
// src/services/email/templates/otp.ts
|
|
3564
|
+
function generateOTPEmailHTML(options) {
|
|
3565
|
+
const { code, expiresInMinutes, userName, appName } = options;
|
|
3566
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3567
|
+
return `
|
|
3568
|
+
<!DOCTYPE html>
|
|
3569
|
+
<html lang="en">
|
|
3570
|
+
<head>
|
|
3571
|
+
<meta charset="UTF-8">
|
|
3572
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3573
|
+
<title>Your Verification Code</title>
|
|
3574
|
+
</head>
|
|
3575
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc;">
|
|
3576
|
+
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
3577
|
+
<div style="background: white; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07); overflow: hidden;">
|
|
3578
|
+
<!-- Header -->
|
|
3579
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 40px 20px;">
|
|
3580
|
+
<h1 style="margin: 0; font-size: 24px; font-weight: 600;">${appName}</h1>
|
|
3581
|
+
<p style="margin: 10px 0 0; opacity: 0.9;">Verification Code</p>
|
|
3582
|
+
</div>
|
|
3583
|
+
|
|
3584
|
+
<!-- Content -->
|
|
3585
|
+
<div style="padding: 40px 30px;">
|
|
3586
|
+
<p style="font-size: 16px; margin-bottom: 20px;">${greeting},</p>
|
|
3587
|
+
<p style="color: #4a5568;">Use the following code to verify your identity:</p>
|
|
3588
|
+
|
|
3589
|
+
<!-- Code Box -->
|
|
3590
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
3591
|
+
<div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); border: 2px dashed #cbd5e0; border-radius: 12px; padding: 25px; display: inline-block;">
|
|
3592
|
+
<span style="font-size: 36px; font-weight: 700; color: #4c51bf; letter-spacing: 8px; font-family: 'Monaco', 'Menlo', monospace;">
|
|
3593
|
+
${code}
|
|
3594
|
+
</span>
|
|
3595
|
+
</div>
|
|
3596
|
+
</div>
|
|
3597
|
+
|
|
3598
|
+
<!-- Expiry Warning -->
|
|
3599
|
+
<div style="background: #fef7e0; border: 1px solid #f6e05e; border-radius: 8px; padding: 12px; margin: 20px 0; color: #744210; text-align: center; font-weight: 500;">
|
|
3600
|
+
\u23F1\uFE0F This code expires in ${expiresInMinutes} minutes
|
|
3601
|
+
</div>
|
|
3602
|
+
|
|
3603
|
+
<!-- Security Notice -->
|
|
3604
|
+
<div style="background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; padding: 12px; margin: 20px 0; color: #c53030; font-size: 14px;">
|
|
3605
|
+
\u{1F512} Never share this code with anyone. ${appName} will never ask for your code.
|
|
3606
|
+
</div>
|
|
3607
|
+
|
|
3608
|
+
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
|
|
3609
|
+
If you didn't request this code, you can safely ignore this email.
|
|
3610
|
+
</p>
|
|
3611
|
+
</div>
|
|
3612
|
+
|
|
3613
|
+
<!-- Footer -->
|
|
3614
|
+
<div style="background: #f7fafc; padding: 20px 30px; text-align: center; border-top: 1px solid #e2e8f0;">
|
|
3615
|
+
<p style="color: #a0aec0; font-size: 12px; margin: 0;">
|
|
3616
|
+
© ${(/* @__PURE__ */ new Date()).getFullYear()} ${appName}. All rights reserved.
|
|
3617
|
+
</p>
|
|
3618
|
+
</div>
|
|
3619
|
+
</div>
|
|
3620
|
+
</div>
|
|
3621
|
+
</body>
|
|
3622
|
+
</html>
|
|
3623
|
+
`;
|
|
3624
|
+
}
|
|
3625
|
+
function generateOTPEmailText(options) {
|
|
3626
|
+
const { code, expiresInMinutes, userName, appName } = options;
|
|
3627
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3628
|
+
return `${greeting},
|
|
3629
|
+
|
|
3630
|
+
Your ${appName} verification code is: ${code}
|
|
3631
|
+
|
|
3632
|
+
This code expires in ${expiresInMinutes} minutes.
|
|
3633
|
+
|
|
3634
|
+
Never share this code with anyone. ${appName} will never ask for your code.
|
|
3635
|
+
|
|
3636
|
+
If you didn't request this code, you can safely ignore this email.
|
|
3637
|
+
|
|
3638
|
+
- The ${appName} Team`;
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
// src/services/email/templates/verification.ts
|
|
3642
|
+
function generateVerificationEmailHTML(options) {
|
|
3643
|
+
const { verificationUrl, userName, expiresInHours, appName } = options;
|
|
3644
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3645
|
+
return `
|
|
3646
|
+
<!DOCTYPE html>
|
|
3647
|
+
<html lang="en">
|
|
3648
|
+
<head>
|
|
3649
|
+
<meta charset="UTF-8">
|
|
3650
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3651
|
+
<title>Verify Your Email</title>
|
|
3652
|
+
</head>
|
|
3653
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc;">
|
|
3654
|
+
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
3655
|
+
<div style="background: white; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07); overflow: hidden;">
|
|
3656
|
+
<!-- Header -->
|
|
3657
|
+
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; text-align: center; padding: 40px 20px;">
|
|
3658
|
+
<h1 style="margin: 0; font-size: 24px; font-weight: 600;">${appName}</h1>
|
|
3659
|
+
<p style="margin: 10px 0 0; opacity: 0.9;">Email Verification</p>
|
|
3660
|
+
</div>
|
|
3661
|
+
|
|
3662
|
+
<!-- Content -->
|
|
3663
|
+
<div style="padding: 40px 30px;">
|
|
3664
|
+
<p style="font-size: 16px; margin-bottom: 20px;">${greeting},</p>
|
|
3665
|
+
<p style="color: #4a5568;">Please verify your email address by clicking the button below:</p>
|
|
3666
|
+
|
|
3667
|
+
<!-- Button -->
|
|
3668
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
3669
|
+
<a href="${verificationUrl}" style="display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
|
3670
|
+
Verify Email Address
|
|
3671
|
+
</a>
|
|
3672
|
+
</div>
|
|
3673
|
+
|
|
3674
|
+
<!-- Expiry Notice -->
|
|
3675
|
+
<div style="background: #fef7e0; border: 1px solid #f6e05e; border-radius: 8px; padding: 12px; margin: 20px 0; color: #744210; text-align: center;">
|
|
3676
|
+
\u23F1\uFE0F This link expires in ${expiresInHours} hour${expiresInHours > 1 ? "s" : ""}
|
|
3677
|
+
</div>
|
|
3678
|
+
|
|
3679
|
+
<!-- Alternative Link -->
|
|
3680
|
+
<p style="color: #718096; font-size: 14px; margin-top: 20px;">
|
|
3681
|
+
If the button doesn't work, copy and paste this link into your browser:
|
|
3682
|
+
</p>
|
|
3683
|
+
<p style="background: #f7fafc; padding: 12px; border-radius: 6px; word-break: break-all; font-size: 12px; color: #4a5568;">
|
|
3684
|
+
${verificationUrl}
|
|
3685
|
+
</p>
|
|
3686
|
+
|
|
3687
|
+
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
|
|
3688
|
+
If you didn't create an account, you can safely ignore this email.
|
|
3689
|
+
</p>
|
|
3690
|
+
</div>
|
|
3691
|
+
|
|
3692
|
+
<!-- Footer -->
|
|
3693
|
+
<div style="background: #f7fafc; padding: 20px 30px; text-align: center; border-top: 1px solid #e2e8f0;">
|
|
3694
|
+
<p style="color: #a0aec0; font-size: 12px; margin: 0;">
|
|
3695
|
+
© ${(/* @__PURE__ */ new Date()).getFullYear()} ${appName}. All rights reserved.
|
|
3696
|
+
</p>
|
|
3697
|
+
</div>
|
|
3698
|
+
</div>
|
|
3699
|
+
</div>
|
|
3700
|
+
</body>
|
|
3701
|
+
</html>
|
|
3702
|
+
`;
|
|
3703
|
+
}
|
|
3704
|
+
function generateVerificationEmailText(options) {
|
|
3705
|
+
const { verificationUrl, userName, expiresInHours, appName } = options;
|
|
3706
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3707
|
+
return `${greeting},
|
|
3708
|
+
|
|
3709
|
+
Please verify your email address by clicking the link below:
|
|
3710
|
+
|
|
3711
|
+
${verificationUrl}
|
|
3712
|
+
|
|
3713
|
+
This link expires in ${expiresInHours} hour${expiresInHours > 1 ? "s" : ""}.
|
|
3714
|
+
|
|
3715
|
+
If you didn't create an account, you can safely ignore this email.
|
|
3716
|
+
|
|
3717
|
+
- The ${appName} Team`;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
// src/services/email/templates/magic-link.ts
|
|
3721
|
+
function generateMagicLinkEmailHTML(options) {
|
|
3722
|
+
const { magicLinkUrl, expiresInMinutes, userName, appName } = options;
|
|
3723
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3724
|
+
return `
|
|
3725
|
+
<!DOCTYPE html>
|
|
3726
|
+
<html lang="en">
|
|
3727
|
+
<head>
|
|
3728
|
+
<meta charset="UTF-8">
|
|
3729
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3730
|
+
<title>Sign in to ${appName}</title>
|
|
3731
|
+
</head>
|
|
3732
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc;">
|
|
3733
|
+
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
3734
|
+
<div style="background: white; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07); overflow: hidden;">
|
|
3735
|
+
<!-- Header -->
|
|
3736
|
+
<div style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; text-align: center; padding: 40px 20px;">
|
|
3737
|
+
<h1 style="margin: 0; font-size: 24px; font-weight: 600;">${appName}</h1>
|
|
3738
|
+
<p style="margin: 10px 0 0; opacity: 0.9;">Sign In Link</p>
|
|
3739
|
+
</div>
|
|
3740
|
+
|
|
3741
|
+
<!-- Content -->
|
|
3742
|
+
<div style="padding: 40px 30px;">
|
|
3743
|
+
<p style="font-size: 16px; margin-bottom: 20px;">${greeting},</p>
|
|
3744
|
+
<p style="color: #4a5568;">Click the button below to sign in to your account:</p>
|
|
3745
|
+
|
|
3746
|
+
<!-- Button -->
|
|
3747
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
3748
|
+
<a href="${magicLinkUrl}" style="display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
|
3749
|
+
Sign In to ${appName}
|
|
3750
|
+
</a>
|
|
3751
|
+
</div>
|
|
3752
|
+
|
|
3753
|
+
<!-- Expiry Notice -->
|
|
3754
|
+
<div style="background: #fef7e0; border: 1px solid #f6e05e; border-radius: 8px; padding: 12px; margin: 20px 0; color: #744210; text-align: center;">
|
|
3755
|
+
\u23F1\uFE0F This link expires in ${expiresInMinutes} minutes
|
|
3756
|
+
</div>
|
|
3757
|
+
|
|
3758
|
+
<!-- Security Notice -->
|
|
3759
|
+
<div style="background: #e0e7ff; border: 1px solid #818cf8; border-radius: 8px; padding: 12px; margin: 20px 0; color: #3730a3; font-size: 14px;">
|
|
3760
|
+
\u{1F512} This is a single-use link. After you click it, you'll be signed in automatically.
|
|
3761
|
+
</div>
|
|
3762
|
+
|
|
3763
|
+
<!-- Alternative Link -->
|
|
3764
|
+
<p style="color: #718096; font-size: 14px; margin-top: 20px;">
|
|
3765
|
+
If the button doesn't work, copy and paste this link into your browser:
|
|
3766
|
+
</p>
|
|
3767
|
+
<p style="background: #f7fafc; padding: 12px; border-radius: 6px; word-break: break-all; font-size: 12px; color: #4a5568;">
|
|
3768
|
+
${magicLinkUrl}
|
|
3769
|
+
</p>
|
|
3770
|
+
|
|
3771
|
+
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
|
|
3772
|
+
If you didn't request this sign-in link, you can safely ignore this email.
|
|
3773
|
+
</p>
|
|
3774
|
+
</div>
|
|
3775
|
+
|
|
3776
|
+
<!-- Footer -->
|
|
3777
|
+
<div style="background: #f7fafc; padding: 20px 30px; text-align: center; border-top: 1px solid #e2e8f0;">
|
|
3778
|
+
<p style="color: #a0aec0; font-size: 12px; margin: 0;">
|
|
3779
|
+
© ${(/* @__PURE__ */ new Date()).getFullYear()} ${appName}. All rights reserved.
|
|
3780
|
+
</p>
|
|
3781
|
+
</div>
|
|
3782
|
+
</div>
|
|
3783
|
+
</div>
|
|
3784
|
+
</body>
|
|
3785
|
+
</html>
|
|
3786
|
+
`;
|
|
3787
|
+
}
|
|
3788
|
+
function generateMagicLinkEmailText(options) {
|
|
3789
|
+
const { magicLinkUrl, expiresInMinutes, userName, appName } = options;
|
|
3790
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3791
|
+
return `${greeting},
|
|
3792
|
+
|
|
3793
|
+
Click the link below to sign in to your ${appName} account:
|
|
3794
|
+
|
|
3795
|
+
${magicLinkUrl}
|
|
3796
|
+
|
|
3797
|
+
This link expires in ${expiresInMinutes} minutes.
|
|
3798
|
+
|
|
3799
|
+
This is a single-use link. After you click it, you'll be signed in automatically.
|
|
3800
|
+
|
|
3801
|
+
If you didn't request this sign-in link, you can safely ignore this email.
|
|
3802
|
+
|
|
3803
|
+
- The ${appName} Team`;
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
// src/services/email/index.ts
|
|
3807
|
+
var EmailService = class {
|
|
3808
|
+
provider;
|
|
3809
|
+
fromEmail;
|
|
3810
|
+
fromName;
|
|
3811
|
+
devMode;
|
|
3812
|
+
constructor(config) {
|
|
3813
|
+
this.devMode = config.devMode ?? process.env["NODE_ENV"] !== "production";
|
|
3814
|
+
this.fromEmail = config.fromEmail ?? process.env["EMAIL_FROM_ADDRESS"] ?? "noreply@example.com";
|
|
3815
|
+
this.fromName = config.fromName ?? process.env["EMAIL_FROM_NAME"] ?? "App";
|
|
3816
|
+
if (config.provider) {
|
|
3817
|
+
this.provider = config.provider;
|
|
3818
|
+
} else {
|
|
3819
|
+
const apiKey = config.apiKey ?? process.env["RESEND_API_KEY"] ?? process.env["EMAIL_API_KEY"];
|
|
3820
|
+
if (!apiKey) {
|
|
3821
|
+
throw new Error("Email API key is required (RESEND_API_KEY or EMAIL_API_KEY)");
|
|
3822
|
+
}
|
|
3823
|
+
this.provider = new ResendEmailProvider({
|
|
3824
|
+
apiKey,
|
|
3825
|
+
fromEmail: this.fromEmail,
|
|
3826
|
+
fromName: this.fromName
|
|
3827
|
+
});
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Send email
|
|
3832
|
+
*/
|
|
3833
|
+
async sendEmail(options) {
|
|
3834
|
+
if (this.devMode) {
|
|
3835
|
+
console.log("\u{1F4E7} EMAIL (Development Mode):");
|
|
3836
|
+
console.log("To:", options.to);
|
|
3837
|
+
console.log("Subject:", options.subject);
|
|
3838
|
+
console.log("From:", options.from ?? `${this.fromName} <${this.fromEmail}>`);
|
|
3839
|
+
console.log("Content:", options.html?.substring(0, 200) ?? options.text?.substring(0, 200));
|
|
3840
|
+
console.log("\u2500".repeat(50));
|
|
3841
|
+
return { success: true, messageId: "dev-mode-" + Date.now() };
|
|
3842
|
+
}
|
|
3843
|
+
return this.provider.sendEmail(options);
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Send OTP verification email
|
|
3847
|
+
*/
|
|
3848
|
+
async sendOTPEmail(options) {
|
|
3849
|
+
const { email, code, expiresInMinutes = 10, userName, appName = this.fromName } = options;
|
|
3850
|
+
return this.sendEmail({
|
|
3851
|
+
to: email,
|
|
3852
|
+
subject: `Your ${appName} Verification Code`,
|
|
3853
|
+
html: generateOTPEmailHTML({ code, expiresInMinutes, userName, appName }),
|
|
3854
|
+
text: generateOTPEmailText({ code, expiresInMinutes, userName, appName })
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
/**
|
|
3858
|
+
* Send email verification email
|
|
3859
|
+
*/
|
|
3860
|
+
async sendVerificationEmail(options) {
|
|
3861
|
+
const { email, verificationUrl, userName, expiresInHours = 24, appName = this.fromName } = options;
|
|
3862
|
+
return this.sendEmail({
|
|
3863
|
+
to: email,
|
|
3864
|
+
subject: `Verify your ${appName} email`,
|
|
3865
|
+
html: generateVerificationEmailHTML({ verificationUrl, userName, expiresInHours, appName }),
|
|
3866
|
+
text: generateVerificationEmailText({ verificationUrl, userName, expiresInHours, appName })
|
|
3867
|
+
});
|
|
3868
|
+
}
|
|
3869
|
+
/**
|
|
3870
|
+
* Send magic link email
|
|
3871
|
+
*/
|
|
3872
|
+
async sendMagicLinkEmail(options) {
|
|
3873
|
+
const { email, magicLinkUrl, expiresInMinutes = 15, userName, appName = this.fromName } = options;
|
|
3874
|
+
return this.sendEmail({
|
|
3875
|
+
to: email,
|
|
3876
|
+
subject: `Sign in to ${appName}`,
|
|
3877
|
+
html: generateMagicLinkEmailHTML({ magicLinkUrl, expiresInMinutes, userName, appName }),
|
|
3878
|
+
text: generateMagicLinkEmailText({ magicLinkUrl, expiresInMinutes, userName, appName })
|
|
3879
|
+
});
|
|
3880
|
+
}
|
|
3881
|
+
/**
|
|
3882
|
+
* Send welcome email
|
|
3883
|
+
*/
|
|
3884
|
+
async sendWelcomeEmail(options) {
|
|
3885
|
+
const { email, userName, appName = this.fromName, loginUrl } = options;
|
|
3886
|
+
const greeting = userName ? `Hi ${userName}` : "Welcome";
|
|
3887
|
+
return this.sendEmail({
|
|
3888
|
+
to: email,
|
|
3889
|
+
subject: `Welcome to ${appName}!`,
|
|
3890
|
+
html: `
|
|
3891
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
|
3892
|
+
<h1>${greeting},</h1>
|
|
3893
|
+
<p>Thank you for joining ${appName}!</p>
|
|
3894
|
+
<p>We're excited to have you on board.</p>
|
|
3895
|
+
${loginUrl ? `<p><a href="${loginUrl}" style="display: inline-block; padding: 12px 24px; background: #4F46E5; color: white; text-decoration: none; border-radius: 6px;">Get Started</a></p>` : ""}
|
|
3896
|
+
<p>Best regards,<br>The ${appName} Team</p>
|
|
3897
|
+
</div>
|
|
3898
|
+
`,
|
|
3899
|
+
text: `${greeting},
|
|
3900
|
+
|
|
3901
|
+
Thank you for joining ${appName}!
|
|
3902
|
+
|
|
3903
|
+
We're excited to have you on board.
|
|
3904
|
+
|
|
3905
|
+
${loginUrl ? `Get Started: ${loginUrl}
|
|
3906
|
+
|
|
3907
|
+
` : ""}Best regards,
|
|
3908
|
+
The ${appName} Team`
|
|
3909
|
+
});
|
|
3910
|
+
}
|
|
3911
|
+
/**
|
|
3912
|
+
* Send password reset email
|
|
3913
|
+
*/
|
|
3914
|
+
async sendPasswordResetEmail(options) {
|
|
3915
|
+
const { email, resetUrl, expiresInHours = 1, userName, appName = this.fromName } = options;
|
|
3916
|
+
const greeting = userName ? `Hi ${userName}` : "Hello";
|
|
3917
|
+
return this.sendEmail({
|
|
3918
|
+
to: email,
|
|
3919
|
+
subject: `Reset your ${appName} password`,
|
|
3920
|
+
html: `
|
|
3921
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
|
3922
|
+
<h1>${greeting},</h1>
|
|
3923
|
+
<p>We received a request to reset your password.</p>
|
|
3924
|
+
<p>Click the button below to reset it. This link expires in ${expiresInHours} hour${expiresInHours > 1 ? "s" : ""}.</p>
|
|
3925
|
+
<p><a href="${resetUrl}" style="display: inline-block; padding: 12px 24px; background: #4F46E5; color: white; text-decoration: none; border-radius: 6px;">Reset Password</a></p>
|
|
3926
|
+
<p style="color: #666; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
|
|
3927
|
+
<p>Best regards,<br>The ${appName} Team</p>
|
|
3928
|
+
</div>
|
|
3929
|
+
`,
|
|
3930
|
+
text: `${greeting},
|
|
3931
|
+
|
|
3932
|
+
We received a request to reset your password.
|
|
3933
|
+
|
|
3934
|
+
Click the link below to reset it. This link expires in ${expiresInHours} hour${expiresInHours > 1 ? "s" : ""}.
|
|
3935
|
+
|
|
3936
|
+
${resetUrl}
|
|
3937
|
+
|
|
3938
|
+
If you didn't request this, you can safely ignore this email.
|
|
3939
|
+
|
|
3940
|
+
Best regards,
|
|
3941
|
+
The ${appName} Team`
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* Send invitation email
|
|
3946
|
+
*/
|
|
3947
|
+
async sendInvitationEmail(options) {
|
|
3948
|
+
const {
|
|
3949
|
+
email,
|
|
3950
|
+
invitationUrl,
|
|
3951
|
+
inviterName,
|
|
3952
|
+
organizationName,
|
|
3953
|
+
roleName,
|
|
3954
|
+
expiresInDays = 7,
|
|
3955
|
+
appName = this.fromName
|
|
3956
|
+
} = options;
|
|
3957
|
+
const roleText = roleName ? ` as a ${roleName}` : "";
|
|
3958
|
+
return this.sendEmail({
|
|
3959
|
+
to: email,
|
|
3960
|
+
subject: `You're invited to join ${organizationName} on ${appName}`,
|
|
3961
|
+
html: `
|
|
3962
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
|
3963
|
+
<h1>You're Invited!</h1>
|
|
3964
|
+
<p>${inviterName} has invited you to join <strong>${organizationName}</strong>${roleText}.</p>
|
|
3965
|
+
<p>Click the button below to accept the invitation. This link expires in ${expiresInDays} day${expiresInDays > 1 ? "s" : ""}.</p>
|
|
3966
|
+
<p><a href="${invitationUrl}" style="display: inline-block; padding: 12px 24px; background: #4F46E5; color: white; text-decoration: none; border-radius: 6px;">Accept Invitation</a></p>
|
|
3967
|
+
<p style="color: #666; font-size: 14px;">If you don't want to join, you can safely ignore this email.</p>
|
|
3968
|
+
<p>Best regards,<br>The ${appName} Team</p>
|
|
3969
|
+
</div>
|
|
3970
|
+
`,
|
|
3971
|
+
text: `You're Invited!
|
|
3972
|
+
|
|
3973
|
+
${inviterName} has invited you to join ${organizationName}${roleText}.
|
|
3974
|
+
|
|
3975
|
+
Click the link below to accept the invitation. This link expires in ${expiresInDays} day${expiresInDays > 1 ? "s" : ""}.
|
|
3976
|
+
|
|
3977
|
+
${invitationUrl}
|
|
3978
|
+
|
|
3979
|
+
If you don't want to join, you can safely ignore this email.
|
|
3980
|
+
|
|
3981
|
+
Best regards,
|
|
3982
|
+
The ${appName} Team`
|
|
3983
|
+
});
|
|
3984
|
+
}
|
|
3985
|
+
};
|
|
3986
|
+
function createEmailService(config) {
|
|
3987
|
+
return new EmailService(config);
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
// src/services/sms/netgsm-provider.ts
|
|
3991
|
+
var NetGSMProvider = class {
|
|
3992
|
+
config;
|
|
3993
|
+
constructor(config) {
|
|
3994
|
+
this.config = {
|
|
3995
|
+
apiUrl: "https://api.netgsm.com.tr/sms/send/otp",
|
|
3996
|
+
...config
|
|
3997
|
+
};
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* Send SMS via NetGSM API
|
|
4001
|
+
*/
|
|
4002
|
+
async sendSMS(options) {
|
|
4003
|
+
try {
|
|
4004
|
+
const xmlData = `<?xml version='1.0' encoding='iso-8859-9'?>
|
|
4005
|
+
<mainbody>
|
|
4006
|
+
<header>
|
|
4007
|
+
<usercode>${this.escapeXml(this.config.username)}</usercode>
|
|
4008
|
+
<password>${this.escapeXml(this.config.password)}</password>
|
|
4009
|
+
<msgheader>${this.escapeXml(options.from ?? this.config.header)}</msgheader>
|
|
4010
|
+
<encoding>TR</encoding>
|
|
4011
|
+
</header>
|
|
4012
|
+
<body>
|
|
4013
|
+
<msg><![CDATA[${options.message}]]></msg>
|
|
4014
|
+
<no>${this.sanitizePhoneNumber(options.to)}</no>
|
|
4015
|
+
</body>
|
|
4016
|
+
</mainbody>`;
|
|
4017
|
+
const response = await fetch(this.config.apiUrl, {
|
|
4018
|
+
method: "POST",
|
|
4019
|
+
headers: {
|
|
4020
|
+
"Content-Type": "application/xml"
|
|
4021
|
+
},
|
|
4022
|
+
body: xmlData
|
|
4023
|
+
});
|
|
4024
|
+
if (!response.ok) {
|
|
4025
|
+
return {
|
|
4026
|
+
success: false,
|
|
4027
|
+
error: `HTTP ${response.status}: ${response.statusText}`
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
const responseText = await response.text();
|
|
4031
|
+
const result = this.parseResponse(responseText);
|
|
4032
|
+
if (result.success) {
|
|
4033
|
+
return {
|
|
4034
|
+
success: true,
|
|
4035
|
+
messageId: result.code
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
return {
|
|
4039
|
+
success: false,
|
|
4040
|
+
error: `NetGSM error code: ${result.code} - ${this.getErrorMessage(result.code ?? "")}`
|
|
4041
|
+
};
|
|
4042
|
+
} catch (error) {
|
|
4043
|
+
return {
|
|
4044
|
+
success: false,
|
|
4045
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
/**
|
|
4050
|
+
* Parse NetGSM XML response
|
|
4051
|
+
*/
|
|
4052
|
+
parseResponse(xml) {
|
|
4053
|
+
try {
|
|
4054
|
+
const codeMatch = xml.match(/<code>(.*?)<\/code>/);
|
|
4055
|
+
if (!codeMatch) {
|
|
4056
|
+
const trimmed = xml.trim();
|
|
4057
|
+
if (trimmed.length < 20 && /^\d+$/.test(trimmed)) {
|
|
4058
|
+
if (trimmed === "00" || trimmed === "0") {
|
|
4059
|
+
return { success: true, code: trimmed };
|
|
4060
|
+
}
|
|
4061
|
+
return { success: false, code: trimmed };
|
|
4062
|
+
}
|
|
4063
|
+
return { success: false };
|
|
4064
|
+
}
|
|
4065
|
+
const code = codeMatch[1].trim();
|
|
4066
|
+
if (code === "00" || code === "0" || code.length > 10) {
|
|
4067
|
+
return { success: true, code };
|
|
4068
|
+
}
|
|
4069
|
+
return { success: false, code };
|
|
4070
|
+
} catch {
|
|
4071
|
+
return { success: false };
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
/**
|
|
4075
|
+
* Get human-readable error message
|
|
4076
|
+
*/
|
|
4077
|
+
getErrorMessage(code) {
|
|
4078
|
+
const errors = {
|
|
4079
|
+
"20": "Message text is missing",
|
|
4080
|
+
"30": "Invalid credentials",
|
|
4081
|
+
"40": "Sender ID not approved",
|
|
4082
|
+
"50": "Invalid recipient number",
|
|
4083
|
+
"51": "Duplicate recipient",
|
|
4084
|
+
"60": "OTP sending blocked",
|
|
4085
|
+
"70": "Incorrect parameter format",
|
|
4086
|
+
"80": "Query limit exceeded",
|
|
4087
|
+
"85": "Same content sent too frequently"
|
|
4088
|
+
};
|
|
4089
|
+
return errors[code] ?? "Unknown error";
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* Escape XML special characters
|
|
4093
|
+
*/
|
|
4094
|
+
escapeXml(unsafe) {
|
|
4095
|
+
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4096
|
+
}
|
|
4097
|
+
/**
|
|
4098
|
+
* Sanitize phone number
|
|
4099
|
+
*/
|
|
4100
|
+
sanitizePhoneNumber(phone) {
|
|
4101
|
+
let cleaned = phone.replace(/[\s\-\(\)]/g, "");
|
|
4102
|
+
if (cleaned.startsWith("+")) {
|
|
4103
|
+
cleaned = cleaned.substring(1);
|
|
4104
|
+
}
|
|
4105
|
+
if (cleaned.startsWith("5") && cleaned.length === 10) {
|
|
4106
|
+
cleaned = "90" + cleaned;
|
|
4107
|
+
}
|
|
4108
|
+
return cleaned;
|
|
4109
|
+
}
|
|
4110
|
+
};
|
|
4111
|
+
function createNetGSMProvider(config) {
|
|
4112
|
+
return new NetGSMProvider(config);
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
// src/services/sms/index.ts
|
|
4116
|
+
var SMSService = class {
|
|
4117
|
+
provider;
|
|
4118
|
+
senderId;
|
|
4119
|
+
devMode;
|
|
4120
|
+
constructor(config) {
|
|
4121
|
+
this.devMode = config.devMode ?? process.env["NODE_ENV"] !== "production";
|
|
4122
|
+
this.senderId = config.senderId ?? process.env["SMS_SENDER_ID"] ?? "App";
|
|
4123
|
+
this.provider = config.provider ?? null;
|
|
4124
|
+
}
|
|
4125
|
+
/**
|
|
4126
|
+
* Send SMS
|
|
4127
|
+
*/
|
|
4128
|
+
async sendSMS(options) {
|
|
4129
|
+
if (this.devMode) {
|
|
4130
|
+
console.log("\u{1F4F1} SMS (Development Mode):");
|
|
4131
|
+
console.log("To:", options.to);
|
|
4132
|
+
console.log("From:", options.from ?? this.senderId);
|
|
4133
|
+
console.log("Message:", options.message);
|
|
4134
|
+
console.log("\u2500".repeat(50));
|
|
4135
|
+
return { success: true, messageId: "dev-mode-" + Date.now() };
|
|
4136
|
+
}
|
|
4137
|
+
if (!this.provider) {
|
|
4138
|
+
return { success: false, error: "SMS provider not configured" };
|
|
4139
|
+
}
|
|
4140
|
+
return this.provider.sendSMS({
|
|
4141
|
+
...options,
|
|
4142
|
+
from: options.from ?? this.senderId
|
|
4143
|
+
});
|
|
4144
|
+
}
|
|
4145
|
+
/**
|
|
4146
|
+
* Send OTP SMS
|
|
4147
|
+
*/
|
|
4148
|
+
async sendOTPSMS(options) {
|
|
4149
|
+
const { phone, code, expiresInMinutes = 10, appName = this.senderId } = options;
|
|
4150
|
+
const message = `Your ${appName} verification code is: ${code}. Valid for ${expiresInMinutes} minutes.`;
|
|
4151
|
+
return this.sendSMS({
|
|
4152
|
+
to: phone,
|
|
4153
|
+
message
|
|
4154
|
+
});
|
|
4155
|
+
}
|
|
4156
|
+
};
|
|
4157
|
+
function createSMSService(config) {
|
|
4158
|
+
return new SMSService(config);
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
// src/services/email-verification/index.ts
|
|
4162
|
+
var EmailVerificationService = class {
|
|
4163
|
+
storage;
|
|
4164
|
+
config;
|
|
4165
|
+
constructor(storage, config) {
|
|
4166
|
+
this.storage = storage;
|
|
4167
|
+
this.config = {
|
|
4168
|
+
callbackPath: "/auth/verify-email",
|
|
4169
|
+
expiresIn: 86400,
|
|
4170
|
+
// 24 hours
|
|
4171
|
+
tokenLength: 32,
|
|
4172
|
+
...config
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
/**
|
|
4176
|
+
* Create verification token for an email
|
|
4177
|
+
*/
|
|
4178
|
+
async createVerificationToken(email, options) {
|
|
4179
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
4180
|
+
const token = await generateRandomHex(this.config.tokenLength);
|
|
4181
|
+
const tokenHash = await sha256Hex(token);
|
|
4182
|
+
const expiresAt = new Date(Date.now() + this.config.expiresIn * 1e3);
|
|
4183
|
+
await this.deleteTokensByEmail(normalizedEmail);
|
|
4184
|
+
const tokenData = {
|
|
4185
|
+
email: normalizedEmail,
|
|
4186
|
+
tokenHash,
|
|
4187
|
+
expiresAt: expiresAt.toISOString(),
|
|
4188
|
+
createdBy: options?.createdBy
|
|
4189
|
+
};
|
|
4190
|
+
await this.storage.set(`email-verify:hash:${tokenHash}`, tokenData, this.config.expiresIn);
|
|
4191
|
+
await this.storage.set(`email-verify:email:${normalizedEmail}`, tokenHash, this.config.expiresIn);
|
|
4192
|
+
const params = new URLSearchParams({ token });
|
|
4193
|
+
const verificationUrl = `${this.config.baseUrl}${this.config.callbackPath}?${params}`;
|
|
4194
|
+
return {
|
|
4195
|
+
success: true,
|
|
4196
|
+
token,
|
|
4197
|
+
verificationUrl,
|
|
4198
|
+
expiresAt
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
/**
|
|
4202
|
+
* Verify an email token
|
|
4203
|
+
*/
|
|
4204
|
+
async verifyToken(token) {
|
|
4205
|
+
const tokenHash = await sha256Hex(token);
|
|
4206
|
+
const tokenData = await this.storage.get(`email-verify:hash:${tokenHash}`);
|
|
4207
|
+
if (!tokenData) {
|
|
4208
|
+
return { success: false, error: "Invalid or expired verification token" };
|
|
4209
|
+
}
|
|
4210
|
+
if (tokenData.usedAt) {
|
|
4211
|
+
return { success: false, error: "Verification token already used" };
|
|
4212
|
+
}
|
|
4213
|
+
if (new Date(tokenData.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
4214
|
+
await this.deleteToken(tokenHash);
|
|
4215
|
+
return { success: false, error: "Verification token expired" };
|
|
4216
|
+
}
|
|
4217
|
+
tokenData.usedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4218
|
+
await this.storage.set(`email-verify:hash:${tokenHash}`, tokenData, 300);
|
|
4219
|
+
await this.storage.delete(`email-verify:email:${tokenData.email}`);
|
|
4220
|
+
return {
|
|
4221
|
+
success: true,
|
|
4222
|
+
email: tokenData.email
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
4225
|
+
/**
|
|
4226
|
+
* Check verification status for an email
|
|
4227
|
+
*/
|
|
4228
|
+
async getStatus(email) {
|
|
4229
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
4230
|
+
const tokenHash = await this.storage.get(`email-verify:email:${normalizedEmail}`);
|
|
4231
|
+
if (tokenHash) {
|
|
4232
|
+
const tokenData = await this.storage.get(`email-verify:hash:${tokenHash}`);
|
|
4233
|
+
if (tokenData && !tokenData.usedAt) {
|
|
4234
|
+
return {
|
|
4235
|
+
email: normalizedEmail,
|
|
4236
|
+
verified: false,
|
|
4237
|
+
pendingVerification: true,
|
|
4238
|
+
expiresAt: new Date(tokenData.expiresAt)
|
|
4239
|
+
};
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
return {
|
|
4243
|
+
email: normalizedEmail,
|
|
4244
|
+
verified: false,
|
|
4245
|
+
pendingVerification: false
|
|
4246
|
+
};
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Resend verification email
|
|
4250
|
+
* Returns a new token for the same email
|
|
4251
|
+
*/
|
|
4252
|
+
async resendVerification(email, options) {
|
|
4253
|
+
return this.createVerificationToken(email, options);
|
|
4254
|
+
}
|
|
4255
|
+
/**
|
|
4256
|
+
* Cancel pending verification
|
|
4257
|
+
*/
|
|
4258
|
+
async cancelVerification(email) {
|
|
4259
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
4260
|
+
await this.deleteTokensByEmail(normalizedEmail);
|
|
4261
|
+
}
|
|
4262
|
+
/**
|
|
4263
|
+
* Delete token by hash
|
|
4264
|
+
*/
|
|
4265
|
+
async deleteToken(tokenHash) {
|
|
4266
|
+
const tokenData = await this.storage.get(`email-verify:hash:${tokenHash}`);
|
|
4267
|
+
if (tokenData) {
|
|
4268
|
+
await this.storage.delete(`email-verify:email:${tokenData.email}`);
|
|
4269
|
+
}
|
|
4270
|
+
await this.storage.delete(`email-verify:hash:${tokenHash}`);
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* Delete all tokens for an email
|
|
4274
|
+
*/
|
|
4275
|
+
async deleteTokensByEmail(email) {
|
|
4276
|
+
const tokenHash = await this.storage.get(`email-verify:email:${email}`);
|
|
4277
|
+
if (tokenHash) {
|
|
4278
|
+
await this.storage.delete(`email-verify:hash:${tokenHash}`);
|
|
4279
|
+
}
|
|
4280
|
+
await this.storage.delete(`email-verify:email:${email}`);
|
|
4281
|
+
}
|
|
4282
|
+
};
|
|
4283
|
+
function createEmailVerificationService(storage, config) {
|
|
4284
|
+
return new EmailVerificationService(storage, config);
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
// src/index.ts
|
|
4288
|
+
function createAuth(config) {
|
|
4289
|
+
return createAuthEngine(config);
|
|
4290
|
+
}
|
|
4291
|
+
|
|
4292
|
+
export { AppleProvider, EmailService, EmailVerificationService, GitHubProvider, GoogleProvider, InvitationService, MagicLinkProvider, MicrosoftProvider, MultiStrategyTenantResolver, NetGSMProvider, OAuthManager, ParsAuthEngine, PasswordProvider, ResendEmailProvider, SMSService, TOTPProvider, TenantManager, TenantResolver, WebAuthnProvider, createAuth, createDrizzleAdapter, createEmailService, createEmailVerificationService, createInvitationService, createMagicLinkProvider, createMultiStrategyResolver, createNetGSMProvider, createOAuthManager, createPasswordProvider, createResendProvider, createSMSService, createTOTPProvider, createTenantManager, createTenantResolver, createWebAuthnProvider, defaultConfig, generatePKCE, generateState, mergeConfig, validateConfig };
|
|
4293
|
+
//# sourceMappingURL=index.js.map
|
|
4294
|
+
//# sourceMappingURL=index.js.map
|