own-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.
Files changed (45) hide show
  1. package/dist/auth-engine.d.ts +227 -0
  2. package/dist/auth-engine.d.ts.map +1 -0
  3. package/dist/auth-engine.js +989 -0
  4. package/dist/auth-engine.js.map +1 -0
  5. package/dist/crypto.d.ts +8 -0
  6. package/dist/crypto.d.ts.map +1 -0
  7. package/dist/crypto.js +69 -0
  8. package/dist/crypto.js.map +1 -0
  9. package/dist/errors.d.ts +9 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +19 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/index.d.ts +14 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +7 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/memory-storage.d.ts +54 -0
  18. package/dist/memory-storage.d.ts.map +1 -0
  19. package/dist/memory-storage.js +244 -0
  20. package/dist/memory-storage.js.map +1 -0
  21. package/dist/normalise.d.ts +5 -0
  22. package/dist/normalise.d.ts.map +1 -0
  23. package/dist/normalise.js +18 -0
  24. package/dist/normalise.js.map +1 -0
  25. package/dist/permissions.d.ts +5 -0
  26. package/dist/permissions.d.ts.map +1 -0
  27. package/dist/permissions.js +31 -0
  28. package/dist/permissions.js.map +1 -0
  29. package/dist/providers.d.ts +35 -0
  30. package/dist/providers.d.ts.map +1 -0
  31. package/dist/providers.js +31 -0
  32. package/dist/providers.js.map +1 -0
  33. package/dist/rate-limit.d.ts +20 -0
  34. package/dist/rate-limit.d.ts.map +1 -0
  35. package/dist/rate-limit.js +33 -0
  36. package/dist/rate-limit.js.map +1 -0
  37. package/dist/storage.d.ts +43 -0
  38. package/dist/storage.d.ts.map +1 -0
  39. package/dist/storage.js +2 -0
  40. package/dist/storage.js.map +1 -0
  41. package/dist/types.d.ts +148 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +2 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +25 -0
@@ -0,0 +1,989 @@
1
+ import { AuthError } from "./errors.js";
2
+ import { createId, hashPassword, hashSecret, randomBase64Url, randomNumericCode, safeEqual, verifyPassword } from "./crypto.js";
3
+ import { InMemoryAuthStorage } from "./memory-storage.js";
4
+ import { normalizeEmail, normalizePhone, slugify, isExpired } from "./normalise.js";
5
+ import { roleHasPermission } from "./permissions.js";
6
+ import { ConsoleEmailProvider, ConsoleSmsProvider } from "./providers.js";
7
+ import { enforceRateLimit, InMemoryRateLimitStore } from "./rate-limit.js";
8
+ const minute = 60 * 1000;
9
+ const hour = 60 * minute;
10
+ const day = 24 * hour;
11
+ const defaultTokenTtls = {
12
+ email_verification: day,
13
+ password_reset: hour,
14
+ magic_link: 15 * minute,
15
+ organisation_invite: 7 * day,
16
+ phone_verification: 10 * minute
17
+ };
18
+ export class OwnAuth {
19
+ storage;
20
+ rateLimitStore;
21
+ emailProvider;
22
+ smsProvider;
23
+ baseUrl;
24
+ tokenPepper;
25
+ exposeRawTokens;
26
+ allowMagicLinkSignup;
27
+ allowPhoneSignup;
28
+ redirectAllowlist;
29
+ sessionTtlMs;
30
+ sessionIdleTtlMs;
31
+ tokenTtls;
32
+ smsOtpTtlMs;
33
+ smsMaxAttempts;
34
+ smsCodeLength;
35
+ passwordMinLength;
36
+ constructor(options = {}) {
37
+ this.storage = options.storage ?? new InMemoryAuthStorage();
38
+ this.rateLimitStore = options.rateLimitStore ?? new InMemoryRateLimitStore();
39
+ this.emailProvider = options.emailProvider ?? new ConsoleEmailProvider();
40
+ this.smsProvider = options.smsProvider ?? new ConsoleSmsProvider();
41
+ this.baseUrl = options.baseUrl ?? "http://localhost:3000";
42
+ this.tokenPepper = options.tokenPepper ?? "";
43
+ this.exposeRawTokens = options.exposeRawTokens ?? false;
44
+ this.allowMagicLinkSignup = options.allowMagicLinkSignup ?? true;
45
+ this.allowPhoneSignup = options.allowPhoneSignup ?? true;
46
+ this.redirectAllowlist = options.redirectAllowlist ?? [this.baseUrl];
47
+ this.sessionTtlMs = options.session?.ttlMs ?? 30 * day;
48
+ this.sessionIdleTtlMs = options.session?.idleTtlMs ?? 7 * day;
49
+ this.tokenTtls = { ...defaultTokenTtls, ...options.tokenTtlMs };
50
+ this.smsOtpTtlMs = options.sms?.otpTtlMs ?? 10 * minute;
51
+ this.smsMaxAttempts = options.sms?.maxAttempts ?? 5;
52
+ this.smsCodeLength = options.sms?.codeLength ?? 6;
53
+ this.passwordMinLength = options.password?.minLength ?? 8;
54
+ }
55
+ async createUser(input) {
56
+ const email = input.email ? normalizeEmail(input.email) : null;
57
+ const phone = input.phone ? normalizePhone(input.phone) : null;
58
+ if (email && (await this.storage.getUserByEmail(email))) {
59
+ throw new AuthError("email_already_exists", "A user already exists with that email", 409);
60
+ }
61
+ if (phone && (await this.storage.getUserByPhone(phone))) {
62
+ throw new AuthError("phone_already_exists", "A user already exists with that phone", 409);
63
+ }
64
+ const now = new Date();
65
+ const passwordHash = input.password ? await this.hashPasswordInput(input.password) : null;
66
+ const user = await this.storage.createUser({
67
+ id: createId("usr"),
68
+ email,
69
+ emailVerifiedAt: null,
70
+ phone,
71
+ phoneVerifiedAt: null,
72
+ passwordHash,
73
+ name: input.name ?? null,
74
+ imageUrl: input.imageUrl ?? null,
75
+ disabledAt: null,
76
+ metadata: cloneMetadata(input.metadata),
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ lastLoginAt: null
80
+ });
81
+ if (email && passwordHash) {
82
+ await this.storage.createAccount(this.accountFor(user.id, "password", email, email, null, now));
83
+ }
84
+ if (email && !passwordHash) {
85
+ await this.storage.createAccount(this.accountFor(user.id, "magic_link", email, email, null, now));
86
+ }
87
+ if (phone) {
88
+ await this.storage.createAccount(this.accountFor(user.id, "phone", phone, null, phone, now));
89
+ }
90
+ return user;
91
+ }
92
+ async signUpEmailPassword(input) {
93
+ const email = normalizeEmail(input.email);
94
+ await this.rateLimit("signup", email, 5, 10 * minute);
95
+ if (await this.storage.getUserByEmail(email)) {
96
+ throw new AuthError("email_already_exists", "A user already exists with that email", 409);
97
+ }
98
+ const user = await this.createUser({
99
+ email,
100
+ password: input.password,
101
+ name: input.name,
102
+ metadata: input.metadata
103
+ });
104
+ await this.audit({
105
+ eventType: "user.signed_up",
106
+ actorUserId: user.id,
107
+ targetUserId: user.id,
108
+ context: input.request
109
+ });
110
+ const result = await this.createSession(user, input.request);
111
+ const updatedUser = await this.storage.updateUser(user.id, {
112
+ lastLoginAt: new Date(),
113
+ updatedAt: new Date()
114
+ });
115
+ return { ...result, user: updatedUser ?? user };
116
+ }
117
+ async signInEmailPassword(input) {
118
+ const email = normalizeEmail(input.email);
119
+ await this.rateLimit("signin", email, 10, 10 * minute);
120
+ const user = await this.storage.getUserByEmail(email);
121
+ if (!user?.passwordHash) {
122
+ throw new AuthError("invalid_credentials", "Invalid email or password", 401);
123
+ }
124
+ this.assertUserEnabled(user);
125
+ if (!(await verifyPassword(input.password, user.passwordHash))) {
126
+ throw new AuthError("invalid_credentials", "Invalid email or password", 401);
127
+ }
128
+ const updatedUser = await this.storage.updateUser(user.id, {
129
+ lastLoginAt: new Date(),
130
+ updatedAt: new Date()
131
+ });
132
+ const activeUser = updatedUser ?? user;
133
+ const result = await this.createSession(activeUser, input.request);
134
+ await this.audit({
135
+ eventType: "user.signed_in",
136
+ actorUserId: activeUser.id,
137
+ targetUserId: activeUser.id,
138
+ context: input.request
139
+ });
140
+ return result;
141
+ }
142
+ async getCurrentSession(sessionToken) {
143
+ const tokenHash = this.hash(sessionToken);
144
+ const session = await this.storage.getSessionByTokenHash(tokenHash);
145
+ const now = new Date();
146
+ if (!session || session.revokedAt || isExpired(session.expiresAt, now) || isExpired(session.idleExpiresAt, now)) {
147
+ return null;
148
+ }
149
+ const user = await this.storage.getUserById(session.userId);
150
+ if (!user || user.disabledAt) {
151
+ return null;
152
+ }
153
+ const updatedSession = await this.storage.updateSession(session.id, {
154
+ lastActiveAt: now,
155
+ idleExpiresAt: new Date(now.getTime() + this.sessionIdleTtlMs)
156
+ });
157
+ return {
158
+ session: updatedSession ?? session,
159
+ user
160
+ };
161
+ }
162
+ async requireCurrentSession(sessionToken) {
163
+ const currentSession = await this.getCurrentSession(sessionToken);
164
+ if (!currentSession) {
165
+ throw new AuthError("invalid_session", "Invalid or expired session", 401);
166
+ }
167
+ return currentSession;
168
+ }
169
+ async signOut(sessionToken, context) {
170
+ const tokenHash = this.hash(sessionToken);
171
+ const session = await this.storage.getSessionByTokenHash(tokenHash);
172
+ if (!session || session.revokedAt) {
173
+ return;
174
+ }
175
+ const now = new Date();
176
+ await this.storage.updateSession(session.id, {
177
+ revokedAt: now,
178
+ revokeReason: "user_logout"
179
+ });
180
+ await this.audit({
181
+ eventType: "user.signed_out",
182
+ actorUserId: session.userId,
183
+ targetUserId: session.userId,
184
+ context
185
+ });
186
+ await this.audit({
187
+ eventType: "session.revoked",
188
+ actorUserId: session.userId,
189
+ targetUserId: session.userId,
190
+ context,
191
+ metadata: { reason: "user_logout" }
192
+ });
193
+ }
194
+ async revokeAllSessions(userId, reason = "all_sessions_revoked") {
195
+ const sessions = await this.storage.listSessionsByUserId(userId);
196
+ const now = new Date();
197
+ let revoked = 0;
198
+ for (const session of sessions) {
199
+ if (!session.revokedAt) {
200
+ await this.storage.updateSession(session.id, {
201
+ revokedAt: now,
202
+ revokeReason: reason
203
+ });
204
+ revoked += 1;
205
+ }
206
+ }
207
+ await this.audit({
208
+ eventType: "session.revoked_all",
209
+ actorUserId: userId,
210
+ targetUserId: userId,
211
+ metadata: { reason, revoked }
212
+ });
213
+ return revoked;
214
+ }
215
+ async requestMagicLink(input) {
216
+ const email = normalizeEmail(input.email);
217
+ await this.rateLimit("magic-link", email, 5, 10 * minute);
218
+ this.assertRedirectAllowed(input.redirectUrl);
219
+ const user = await this.storage.getUserByEmail(email);
220
+ if (!user && !this.allowMagicLinkSignup) {
221
+ return { sent: true, expiresAt: null };
222
+ }
223
+ const issued = await this.issueToken("magic_link", {
224
+ userId: user?.id ?? null,
225
+ email,
226
+ ttlMs: this.tokenTtls.magic_link
227
+ });
228
+ const url = this.buildUrl("/auth/magic-link/verify", {
229
+ token: issued.rawToken,
230
+ redirect_url: input.redirectUrl
231
+ });
232
+ await this.emailProvider.send({
233
+ to: email,
234
+ type: "magic_link",
235
+ token: issued.rawToken,
236
+ url,
237
+ expiresAt: issued.token.expiresAt
238
+ });
239
+ await this.audit({
240
+ eventType: "magic_link.requested",
241
+ actorUserId: user?.id ?? null,
242
+ targetUserId: user?.id ?? null,
243
+ context: input.request
244
+ });
245
+ return this.delivery(issued.rawToken, url, issued.token.expiresAt);
246
+ }
247
+ async verifyMagicLink(input) {
248
+ const token = await this.consumeToken(input.token, "magic_link");
249
+ let user = token.userId ? await this.storage.getUserById(token.userId) : null;
250
+ if (!user && token.email) {
251
+ user = await this.storage.getUserByEmail(token.email);
252
+ }
253
+ if (!user && token.email && this.allowMagicLinkSignup) {
254
+ user = await this.createUser({ email: token.email });
255
+ }
256
+ if (!user) {
257
+ throw new AuthError("invalid_token", "Invalid token", 401);
258
+ }
259
+ this.assertUserEnabled(user);
260
+ if (!user.emailVerifiedAt && token.email) {
261
+ user = (await this.storage.updateUser(user.id, {
262
+ emailVerifiedAt: new Date(),
263
+ updatedAt: new Date()
264
+ })) ?? user;
265
+ }
266
+ const result = await this.createSession(user, input.request);
267
+ await this.audit({
268
+ eventType: "magic_link.used",
269
+ actorUserId: user.id,
270
+ targetUserId: user.id,
271
+ context: input.request
272
+ });
273
+ await this.audit({
274
+ eventType: "user.signed_in",
275
+ actorUserId: user.id,
276
+ targetUserId: user.id,
277
+ context: input.request,
278
+ metadata: { method: "magic_link" }
279
+ });
280
+ return result;
281
+ }
282
+ async requestEmailVerification(input) {
283
+ const email = normalizeEmail(input.email);
284
+ await this.rateLimit("email-verification", email, 5, 10 * minute);
285
+ const user = await this.storage.getUserByEmail(email);
286
+ if (!user) {
287
+ return { sent: true, expiresAt: null };
288
+ }
289
+ const issued = await this.issueToken("email_verification", {
290
+ userId: user.id,
291
+ email,
292
+ ttlMs: this.tokenTtls.email_verification
293
+ });
294
+ const url = this.buildUrl("/auth/email/verify", { token: issued.rawToken });
295
+ await this.emailProvider.send({
296
+ to: email,
297
+ type: "email_verification",
298
+ token: issued.rawToken,
299
+ url,
300
+ expiresAt: issued.token.expiresAt
301
+ });
302
+ await this.audit({
303
+ eventType: "email_verification.requested",
304
+ actorUserId: user.id,
305
+ targetUserId: user.id,
306
+ context: input.request
307
+ });
308
+ return this.delivery(issued.rawToken, url, issued.token.expiresAt);
309
+ }
310
+ async verifyEmail(input) {
311
+ const token = await this.consumeToken(input.token, "email_verification");
312
+ const user = token.userId ? await this.storage.getUserById(token.userId) : null;
313
+ if (!user) {
314
+ throw new AuthError("invalid_token", "Invalid token", 401);
315
+ }
316
+ const updatedUser = await this.storage.updateUser(user.id, {
317
+ emailVerifiedAt: new Date(),
318
+ updatedAt: new Date()
319
+ });
320
+ await this.audit({
321
+ eventType: "email.verified",
322
+ actorUserId: user.id,
323
+ targetUserId: user.id,
324
+ context: input.request
325
+ });
326
+ return updatedUser ?? user;
327
+ }
328
+ async requestPasswordReset(input) {
329
+ const email = normalizeEmail(input.email);
330
+ await this.rateLimit("password-reset", email, 5, 15 * minute);
331
+ const user = await this.storage.getUserByEmail(email);
332
+ if (!user) {
333
+ return { sent: true, expiresAt: null };
334
+ }
335
+ const issued = await this.issueToken("password_reset", {
336
+ userId: user.id,
337
+ email,
338
+ ttlMs: this.tokenTtls.password_reset
339
+ });
340
+ const url = this.buildUrl("/auth/password/reset", { token: issued.rawToken });
341
+ await this.emailProvider.send({
342
+ to: email,
343
+ type: "password_reset",
344
+ token: issued.rawToken,
345
+ url,
346
+ expiresAt: issued.token.expiresAt
347
+ });
348
+ await this.audit({
349
+ eventType: "password_reset.requested",
350
+ actorUserId: user.id,
351
+ targetUserId: user.id,
352
+ context: input.request
353
+ });
354
+ return this.delivery(issued.rawToken, url, issued.token.expiresAt);
355
+ }
356
+ async resetPassword(input) {
357
+ const token = await this.consumeToken(input.token, "password_reset");
358
+ const user = token.userId ? await this.storage.getUserById(token.userId) : null;
359
+ if (!user) {
360
+ throw new AuthError("invalid_token", "Invalid token", 401);
361
+ }
362
+ const passwordHash = await this.hashPasswordInput(input.newPassword);
363
+ const updatedUser = await this.storage.updateUser(user.id, {
364
+ passwordHash,
365
+ updatedAt: new Date()
366
+ });
367
+ await this.revokeAllSessions(user.id, "password_reset");
368
+ await this.audit({
369
+ eventType: "password.changed",
370
+ actorUserId: user.id,
371
+ targetUserId: user.id,
372
+ context: input.request
373
+ });
374
+ return updatedUser ?? user;
375
+ }
376
+ async requestSmsOtp(input) {
377
+ const purpose = input.purpose ?? "phone_login";
378
+ const phone = normalizePhone(input.phone);
379
+ await this.rateLimit(`sms-${purpose}`, phone, 5, 15 * minute);
380
+ const user = input.userId
381
+ ? await this.storage.getUserById(input.userId)
382
+ : await this.storage.getUserByPhone(phone);
383
+ const code = randomNumericCode(this.smsCodeLength);
384
+ const now = new Date();
385
+ const expiresAt = new Date(now.getTime() + this.smsOtpTtlMs);
386
+ const otp = await this.storage.createSmsOtp({
387
+ id: createId("otp"),
388
+ phone,
389
+ userId: user?.id ?? null,
390
+ codeHash: this.hash(code),
391
+ purpose,
392
+ expiresAt,
393
+ attempts: 0,
394
+ maxAttempts: this.smsMaxAttempts,
395
+ consumedAt: null,
396
+ createdAt: now,
397
+ lastSentAt: now
398
+ });
399
+ await this.smsProvider.send({
400
+ to: phone,
401
+ purpose,
402
+ code,
403
+ expiresAt
404
+ });
405
+ await this.audit({
406
+ eventType: "sms_otp.sent",
407
+ actorUserId: user?.id ?? null,
408
+ targetUserId: user?.id ?? null,
409
+ context: input.request,
410
+ metadata: { purpose, otpId: otp.id }
411
+ });
412
+ const delivery = { sent: true, expiresAt };
413
+ if (this.exposeRawTokens) {
414
+ delivery.code = code;
415
+ }
416
+ return delivery;
417
+ }
418
+ async verifySmsOtp(input) {
419
+ const purpose = input.purpose ?? "phone_login";
420
+ const phone = normalizePhone(input.phone);
421
+ await this.rateLimit(`sms-verify-${purpose}`, phone, 10, 15 * minute);
422
+ const otp = await this.storage.getLatestSmsOtp(phone, purpose);
423
+ const now = new Date();
424
+ if (!otp || otp.consumedAt || isExpired(otp.expiresAt, now)) {
425
+ throw new AuthError("invalid_otp", "Invalid or expired code", 401);
426
+ }
427
+ if (otp.attempts >= otp.maxAttempts) {
428
+ throw new AuthError("otp_attempts_exceeded", "Too many code attempts", 429);
429
+ }
430
+ const codeHash = this.hash(input.code);
431
+ if (!safeEqual(codeHash, otp.codeHash)) {
432
+ await this.storage.updateSmsOtp(otp.id, { attempts: otp.attempts + 1 });
433
+ throw new AuthError("invalid_otp", "Invalid or expired code", 401);
434
+ }
435
+ await this.storage.updateSmsOtp(otp.id, {
436
+ consumedAt: now,
437
+ attempts: otp.attempts + 1
438
+ });
439
+ let user = otp.userId ? await this.storage.getUserById(otp.userId) : null;
440
+ if (!user) {
441
+ user = await this.storage.getUserByPhone(phone);
442
+ }
443
+ if (!user && purpose === "phone_login" && this.allowPhoneSignup) {
444
+ user = await this.createUser({ phone });
445
+ }
446
+ if (!user) {
447
+ throw new AuthError("user_not_found", "User not found", 404);
448
+ }
449
+ this.assertUserEnabled(user);
450
+ user = (await this.storage.updateUser(user.id, {
451
+ phone,
452
+ phoneVerifiedAt: new Date(),
453
+ updatedAt: new Date()
454
+ })) ?? user;
455
+ await this.audit({
456
+ eventType: "sms_otp.verified",
457
+ actorUserId: user.id,
458
+ targetUserId: user.id,
459
+ context: input.request,
460
+ metadata: { purpose }
461
+ });
462
+ await this.audit({
463
+ eventType: "phone.verified",
464
+ actorUserId: user.id,
465
+ targetUserId: user.id,
466
+ context: input.request
467
+ });
468
+ if (purpose !== "phone_login") {
469
+ return { user, session: null, sessionToken: null };
470
+ }
471
+ const sessionResult = await this.createSession(user, input.request);
472
+ await this.audit({
473
+ eventType: "user.signed_in",
474
+ actorUserId: user.id,
475
+ targetUserId: user.id,
476
+ context: input.request,
477
+ metadata: { method: "phone_otp" }
478
+ });
479
+ return {
480
+ user: sessionResult.user,
481
+ session: sessionResult.session,
482
+ sessionToken: sessionResult.sessionToken
483
+ };
484
+ }
485
+ async createApiKey(input) {
486
+ if (!input.userId && !input.organisationId) {
487
+ throw new AuthError("permission_denied", "API keys need a user or organisation owner", 400);
488
+ }
489
+ if (input.organisationId && input.actorUserId) {
490
+ await this.requirePermission(input.organisationId, input.actorUserId, "manage_api_keys");
491
+ }
492
+ await this.rateLimit("api-key-create", input.organisationId ?? input.userId ?? input.actorUserId ?? "anonymous", 20, hour);
493
+ const prefix = randomBase64Url(6).replace(/[-_]/g, "").slice(0, 8);
494
+ const rawKey = `oa_${prefix}_${randomBase64Url(32)}`;
495
+ const now = new Date();
496
+ const apiKey = await this.storage.createApiKey({
497
+ id: createId("key"),
498
+ keyPrefix: prefix,
499
+ keyHash: this.hash(rawKey),
500
+ name: input.name,
501
+ userId: input.userId ?? null,
502
+ organisationId: input.organisationId ?? null,
503
+ scopes: input.scopes ?? [],
504
+ status: "active",
505
+ expiresAt: input.expiresAt ?? null,
506
+ lastUsedAt: null,
507
+ createdAt: now,
508
+ revokedAt: null,
509
+ revokedBy: null,
510
+ metadata: cloneMetadata(input.metadata)
511
+ });
512
+ await this.audit({
513
+ eventType: "api_key.created",
514
+ actorUserId: input.actorUserId ?? input.userId ?? null,
515
+ targetUserId: input.userId ?? null,
516
+ organisationId: input.organisationId ?? null,
517
+ apiKeyId: apiKey.id,
518
+ context: input.request,
519
+ metadata: { name: input.name, scopes: apiKey.scopes }
520
+ });
521
+ return { apiKey, rawKey };
522
+ }
523
+ async verifyApiKey(rawKey, requiredScopes = []) {
524
+ const prefix = this.extractApiKeyPrefix(rawKey);
525
+ if (!prefix) {
526
+ throw new AuthError("api_key_invalid", "Invalid API key", 401);
527
+ }
528
+ const apiKey = await this.storage.getApiKeyByPrefix(prefix);
529
+ if (!apiKey || !safeEqual(this.hash(rawKey), apiKey.keyHash)) {
530
+ throw new AuthError("api_key_invalid", "Invalid API key", 401);
531
+ }
532
+ if (apiKey.status === "revoked" || apiKey.revokedAt) {
533
+ throw new AuthError("api_key_revoked", "API key has been revoked", 401);
534
+ }
535
+ if (apiKey.expiresAt && isExpired(apiKey.expiresAt)) {
536
+ throw new AuthError("api_key_expired", "API key has expired", 401);
537
+ }
538
+ const hasAllScopes = requiredScopes.every((scope) => apiKey.scopes.includes("*") || apiKey.scopes.includes(scope));
539
+ if (!hasAllScopes) {
540
+ throw new AuthError("insufficient_scope", "API key does not have the required scope", 403);
541
+ }
542
+ const updatedApiKey = await this.storage.updateApiKey(apiKey.id, {
543
+ lastUsedAt: new Date()
544
+ });
545
+ const activeApiKey = updatedApiKey ?? apiKey;
546
+ await this.audit({
547
+ eventType: "api_key.used",
548
+ actorUserId: activeApiKey.userId,
549
+ targetUserId: activeApiKey.userId,
550
+ organisationId: activeApiKey.organisationId,
551
+ apiKeyId: activeApiKey.id,
552
+ metadata: { requiredScopes }
553
+ });
554
+ const user = activeApiKey.userId ? await this.storage.getUserById(activeApiKey.userId) : null;
555
+ const organisation = activeApiKey.organisationId
556
+ ? await this.storage.getOrganisationById(activeApiKey.organisationId)
557
+ : null;
558
+ return {
559
+ apiKey: activeApiKey,
560
+ user,
561
+ organisation
562
+ };
563
+ }
564
+ async revokeApiKey(keyPrefixOrId, revokedBy, context) {
565
+ let apiKey = await this.storage.getApiKeyByPrefix(keyPrefixOrId);
566
+ if (!apiKey) {
567
+ throw new AuthError("api_key_invalid", "Invalid API key", 404);
568
+ }
569
+ const updatedApiKey = await this.storage.updateApiKey(apiKey.id, {
570
+ status: "revoked",
571
+ revokedAt: new Date(),
572
+ revokedBy: revokedBy ?? null
573
+ });
574
+ apiKey = updatedApiKey ?? apiKey;
575
+ await this.audit({
576
+ eventType: "api_key.revoked",
577
+ actorUserId: revokedBy ?? apiKey.userId,
578
+ targetUserId: apiKey.userId,
579
+ organisationId: apiKey.organisationId,
580
+ apiKeyId: apiKey.id,
581
+ context
582
+ });
583
+ return apiKey;
584
+ }
585
+ async createOrganisation(input) {
586
+ const owner = await this.storage.getUserById(input.ownerUserId);
587
+ if (!owner) {
588
+ throw new AuthError("user_not_found", "Owner user not found", 404);
589
+ }
590
+ const now = new Date();
591
+ const baseSlug = slugify(input.slug ?? input.name);
592
+ const slug = await this.uniqueOrganisationSlug(baseSlug);
593
+ const organisation = await this.storage.createOrganisation({
594
+ id: createId("org"),
595
+ name: input.name,
596
+ slug,
597
+ ownerUserId: input.ownerUserId,
598
+ metadata: cloneMetadata(input.metadata),
599
+ createdAt: now,
600
+ updatedAt: now,
601
+ disabledAt: null
602
+ });
603
+ const ownerMembership = await this.storage.createOrganisationMember({
604
+ id: createId("mem"),
605
+ organisationId: organisation.id,
606
+ userId: input.ownerUserId,
607
+ role: "owner",
608
+ status: "active",
609
+ joinedAt: now,
610
+ removedAt: null,
611
+ createdAt: now,
612
+ updatedAt: now
613
+ });
614
+ await this.audit({
615
+ eventType: "organisation.created",
616
+ actorUserId: input.ownerUserId,
617
+ targetUserId: input.ownerUserId,
618
+ organisationId: organisation.id,
619
+ context: input.request,
620
+ metadata: { name: input.name, slug }
621
+ });
622
+ return { organisation, ownerMembership };
623
+ }
624
+ async updateOrganisation(organisationId, input) {
625
+ await this.requirePermission(organisationId, input.actorUserId, "manage_organisation");
626
+ const patch = {
627
+ updatedAt: new Date()
628
+ };
629
+ if (input.name !== undefined)
630
+ patch.name = input.name;
631
+ if (input.slug !== undefined)
632
+ patch.slug = await this.uniqueOrganisationSlug(slugify(input.slug));
633
+ if (input.metadata !== undefined)
634
+ patch.metadata = cloneMetadata(input.metadata);
635
+ const organisation = await this.storage.updateOrganisation(organisationId, patch);
636
+ if (!organisation) {
637
+ throw new AuthError("organisation_not_found", "Organisation not found", 404);
638
+ }
639
+ await this.audit({
640
+ eventType: "organisation.updated",
641
+ actorUserId: input.actorUserId,
642
+ organisationId,
643
+ context: input.request,
644
+ metadata: patch
645
+ });
646
+ return organisation;
647
+ }
648
+ async inviteMember(input) {
649
+ await this.requirePermission(input.organisationId, input.invitedByUserId, "invite_members");
650
+ const email = normalizeEmail(input.email);
651
+ const now = new Date();
652
+ const invitation = await this.storage.createInvitation({
653
+ id: createId("inv"),
654
+ organisationId: input.organisationId,
655
+ email,
656
+ phone: null,
657
+ role: input.role ?? "member",
658
+ invitedByUserId: input.invitedByUserId,
659
+ status: "pending",
660
+ expiresAt: new Date(now.getTime() + this.tokenTtls.organisation_invite),
661
+ acceptedAt: null,
662
+ revokedAt: null,
663
+ createdAt: now
664
+ });
665
+ const issued = await this.issueToken("organisation_invite", {
666
+ userId: null,
667
+ email,
668
+ organisationId: input.organisationId,
669
+ ttlMs: this.tokenTtls.organisation_invite
670
+ });
671
+ const url = this.buildUrl("/auth/invitations/accept", { token: issued.rawToken });
672
+ await this.emailProvider.send({
673
+ to: email,
674
+ type: "organisation_invite",
675
+ token: issued.rawToken,
676
+ url,
677
+ expiresAt: issued.token.expiresAt
678
+ });
679
+ await this.audit({
680
+ eventType: "member.invited",
681
+ actorUserId: input.invitedByUserId,
682
+ organisationId: input.organisationId,
683
+ context: input.request,
684
+ metadata: { email, role: invitation.role, invitationId: invitation.id }
685
+ });
686
+ const result = { invitation };
687
+ if (this.exposeRawTokens) {
688
+ result.token = issued.rawToken;
689
+ result.url = url;
690
+ }
691
+ return result;
692
+ }
693
+ async acceptInvitation(input) {
694
+ const token = await this.consumeToken(input.token, "organisation_invite");
695
+ const organisationId = token.organisationId;
696
+ if (!organisationId) {
697
+ throw new AuthError("invalid_token", "Invalid token", 401);
698
+ }
699
+ const allPending = token.email
700
+ ? await this.storage.getPendingInvitationByOrganisationAndEmail(organisationId, token.email)
701
+ : null;
702
+ if (!allPending) {
703
+ throw new AuthError("invitation_not_found", "Invitation not found", 404);
704
+ }
705
+ if (allPending.status !== "pending") {
706
+ throw new AuthError("invitation_not_pending", "Invitation is not pending", 409);
707
+ }
708
+ if (isExpired(allPending.expiresAt)) {
709
+ await this.storage.updateInvitation(allPending.id, { status: "expired" });
710
+ throw new AuthError("expired_token", "Invitation has expired", 401);
711
+ }
712
+ let user = input.userId ? await this.storage.getUserById(input.userId) : null;
713
+ if (!user && token.email) {
714
+ user = await this.storage.getUserByEmail(token.email);
715
+ }
716
+ if (!user && token.email) {
717
+ user = await this.createUser({ email: token.email });
718
+ user = (await this.storage.updateUser(user.id, {
719
+ emailVerifiedAt: new Date(),
720
+ updatedAt: new Date()
721
+ })) ?? user;
722
+ }
723
+ if (!user) {
724
+ throw new AuthError("user_not_found", "User not found", 404);
725
+ }
726
+ const now = new Date();
727
+ let member = await this.storage.getOrganisationMember(organisationId, user.id);
728
+ if (member) {
729
+ member = (await this.storage.updateOrganisationMember(member.id, {
730
+ role: allPending.role,
731
+ status: "active",
732
+ joinedAt: member.joinedAt ?? now,
733
+ removedAt: null,
734
+ updatedAt: now
735
+ })) ?? member;
736
+ }
737
+ else {
738
+ member = await this.storage.createOrganisationMember({
739
+ id: createId("mem"),
740
+ organisationId,
741
+ userId: user.id,
742
+ role: allPending.role,
743
+ status: "active",
744
+ joinedAt: now,
745
+ removedAt: null,
746
+ createdAt: now,
747
+ updatedAt: now
748
+ });
749
+ }
750
+ const invitation = (await this.storage.updateInvitation(allPending.id, {
751
+ status: "accepted",
752
+ acceptedAt: now
753
+ })) ?? allPending;
754
+ await this.audit({
755
+ eventType: "invite.accepted",
756
+ actorUserId: user.id,
757
+ targetUserId: user.id,
758
+ organisationId,
759
+ context: input.request,
760
+ metadata: { invitationId: invitation.id }
761
+ });
762
+ return { invitation, user, member };
763
+ }
764
+ async changeMemberRole(input) {
765
+ await this.requirePermission(input.organisationId, input.actorUserId, "change_member_roles");
766
+ const organisation = await this.storage.getOrganisationById(input.organisationId);
767
+ const member = await this.storage.getOrganisationMemberById(input.memberId);
768
+ if (!organisation || !member || member.organisationId !== input.organisationId) {
769
+ throw new AuthError("member_not_found", "Member not found", 404);
770
+ }
771
+ if (member.userId === organisation.ownerUserId && input.role !== "owner") {
772
+ throw new AuthError("unsafe_owner_removal", "Transfer ownership before changing owner role", 409);
773
+ }
774
+ const updatedMember = await this.storage.updateOrganisationMember(member.id, {
775
+ role: input.role,
776
+ updatedAt: new Date()
777
+ });
778
+ await this.audit({
779
+ eventType: "member.role_changed",
780
+ actorUserId: input.actorUserId,
781
+ targetUserId: member.userId,
782
+ organisationId: input.organisationId,
783
+ context: input.request,
784
+ metadata: { role: input.role }
785
+ });
786
+ return updatedMember ?? member;
787
+ }
788
+ async removeMember(input) {
789
+ await this.requirePermission(input.organisationId, input.actorUserId, "remove_members");
790
+ const organisation = await this.storage.getOrganisationById(input.organisationId);
791
+ const member = await this.storage.getOrganisationMemberById(input.memberId);
792
+ if (!organisation || !member || member.organisationId !== input.organisationId) {
793
+ throw new AuthError("member_not_found", "Member not found", 404);
794
+ }
795
+ if (member.userId === organisation.ownerUserId) {
796
+ throw new AuthError("unsafe_owner_removal", "Transfer ownership before removing owner", 409);
797
+ }
798
+ const now = new Date();
799
+ const updatedMember = await this.storage.updateOrganisationMember(member.id, {
800
+ status: "removed",
801
+ removedAt: now,
802
+ updatedAt: now
803
+ });
804
+ await this.audit({
805
+ eventType: "member.removed",
806
+ actorUserId: input.actorUserId,
807
+ targetUserId: member.userId,
808
+ organisationId: input.organisationId,
809
+ context: input.request
810
+ });
811
+ return updatedMember ?? member;
812
+ }
813
+ async checkPermission(organisationId, userId, permission) {
814
+ const member = await this.storage.getOrganisationMember(organisationId, userId);
815
+ return Boolean(member && member.status === "active" && roleHasPermission(member.role, permission));
816
+ }
817
+ async requirePermission(organisationId, userId, permission) {
818
+ const member = await this.storage.getOrganisationMember(organisationId, userId);
819
+ if (!member || member.status !== "active" || !roleHasPermission(member.role, permission)) {
820
+ throw new AuthError("permission_denied", "You do not have permission for this action", 403);
821
+ }
822
+ return member;
823
+ }
824
+ async listAuditEvents(filter) {
825
+ return this.storage.listAuditEvents(filter);
826
+ }
827
+ async createSession(user, context) {
828
+ this.assertUserEnabled(user);
829
+ const sessionToken = randomBase64Url(32);
830
+ const now = new Date();
831
+ const session = await this.storage.createSession({
832
+ id: createId("ses"),
833
+ userId: user.id,
834
+ tokenHash: this.hash(sessionToken),
835
+ createdAt: now,
836
+ lastActiveAt: now,
837
+ expiresAt: new Date(now.getTime() + this.sessionTtlMs),
838
+ idleExpiresAt: new Date(now.getTime() + this.sessionIdleTtlMs),
839
+ ipAddress: context?.ipAddress ?? null,
840
+ userAgent: context?.userAgent ?? null,
841
+ revokedAt: null,
842
+ revokeReason: null
843
+ });
844
+ await this.audit({
845
+ eventType: "session.created",
846
+ actorUserId: user.id,
847
+ targetUserId: user.id,
848
+ context,
849
+ metadata: { sessionId: session.id }
850
+ });
851
+ return { user, session, sessionToken };
852
+ }
853
+ accountFor(userId, provider, providerAccountId, providerEmail, providerPhone, now) {
854
+ return {
855
+ id: createId("acct"),
856
+ userId,
857
+ provider,
858
+ providerAccountId,
859
+ providerEmail,
860
+ providerPhone,
861
+ createdAt: now,
862
+ updatedAt: now
863
+ };
864
+ }
865
+ async issueToken(type, input) {
866
+ const rawToken = randomBase64Url(32);
867
+ const now = new Date();
868
+ const token = await this.storage.createToken({
869
+ id: createId("tok"),
870
+ tokenHash: this.hash(rawToken),
871
+ type,
872
+ userId: input.userId,
873
+ email: input.email ?? null,
874
+ phone: input.phone ?? null,
875
+ organisationId: input.organisationId ?? null,
876
+ expiresAt: new Date(now.getTime() + input.ttlMs),
877
+ usedAt: null,
878
+ createdAt: now
879
+ });
880
+ return { rawToken, token };
881
+ }
882
+ async consumeToken(rawToken, type) {
883
+ const token = await this.storage.getTokenByHash(this.hash(rawToken), type);
884
+ if (!token) {
885
+ throw new AuthError("invalid_token", "Invalid token", 401);
886
+ }
887
+ if (token.usedAt) {
888
+ throw new AuthError("token_already_used", "Token has already been used", 401);
889
+ }
890
+ if (isExpired(token.expiresAt)) {
891
+ throw new AuthError("expired_token", "Token has expired", 401);
892
+ }
893
+ const updatedToken = await this.storage.updateToken(token.id, {
894
+ usedAt: new Date()
895
+ });
896
+ return updatedToken ?? token;
897
+ }
898
+ async hashPasswordInput(password) {
899
+ if (password.length < this.passwordMinLength) {
900
+ throw new AuthError("weak_password", `Password must be at least ${this.passwordMinLength} characters`, 400);
901
+ }
902
+ return hashPassword(password);
903
+ }
904
+ hash(value) {
905
+ return hashSecret(value, this.tokenPepper);
906
+ }
907
+ delivery(token, url, expiresAt) {
908
+ const result = { sent: true, expiresAt };
909
+ if (this.exposeRawTokens) {
910
+ result.token = token;
911
+ result.url = url;
912
+ }
913
+ return result;
914
+ }
915
+ buildUrl(pathname, params) {
916
+ const url = new URL(pathname, this.baseUrl);
917
+ for (const [key, value] of Object.entries(params)) {
918
+ if (value) {
919
+ url.searchParams.set(key, value);
920
+ }
921
+ }
922
+ return url.toString();
923
+ }
924
+ assertRedirectAllowed(redirectUrl) {
925
+ if (!redirectUrl) {
926
+ return;
927
+ }
928
+ if (redirectUrl.startsWith("/")) {
929
+ return;
930
+ }
931
+ const parsed = new URL(redirectUrl);
932
+ const allowed = this.redirectAllowlist.some((allowedUrl) => {
933
+ const allowedParsed = new URL(allowedUrl);
934
+ return parsed.origin === allowedParsed.origin;
935
+ });
936
+ if (!allowed) {
937
+ throw new AuthError("redirect_not_allowed", "Redirect URL is not allowed", 400);
938
+ }
939
+ }
940
+ extractApiKeyPrefix(rawKey) {
941
+ const [namespace, prefix] = rawKey.split("_");
942
+ if (namespace !== "oa" || !prefix) {
943
+ return null;
944
+ }
945
+ return prefix;
946
+ }
947
+ async rateLimit(action, identifier, limit, windowMs) {
948
+ await enforceRateLimit(this.rateLimitStore, {
949
+ key: `${action}:${identifier}`,
950
+ limit,
951
+ windowMs
952
+ });
953
+ }
954
+ assertUserEnabled(user) {
955
+ if (user.disabledAt) {
956
+ throw new AuthError("disabled_user", "User is disabled", 403);
957
+ }
958
+ }
959
+ async audit(input) {
960
+ await this.storage.createAuditEvent({
961
+ id: createId("evt"),
962
+ eventType: input.eventType,
963
+ actorUserId: input.actorUserId ?? null,
964
+ targetUserId: input.targetUserId ?? null,
965
+ organisationId: input.organisationId ?? null,
966
+ apiKeyId: input.apiKeyId ?? null,
967
+ ipAddress: input.context?.ipAddress ?? null,
968
+ userAgent: input.context?.userAgent ?? null,
969
+ metadata: cloneMetadata(input.metadata),
970
+ createdAt: new Date()
971
+ });
972
+ }
973
+ async uniqueOrganisationSlug(baseSlug) {
974
+ let candidate = baseSlug;
975
+ let attempt = 1;
976
+ while (await this.storage.getOrganisationBySlug(candidate)) {
977
+ attempt += 1;
978
+ candidate = `${baseSlug}-${attempt}`;
979
+ }
980
+ return candidate;
981
+ }
982
+ }
983
+ export function createOwnAuth(options) {
984
+ return new OwnAuth(options);
985
+ }
986
+ function cloneMetadata(metadata) {
987
+ return metadata ? structuredClone(metadata) : {};
988
+ }
989
+ //# sourceMappingURL=auth-engine.js.map