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.
- package/dist/auth-engine.d.ts +227 -0
- package/dist/auth-engine.d.ts.map +1 -0
- package/dist/auth-engine.js +989 -0
- package/dist/auth-engine.js.map +1 -0
- package/dist/crypto.d.ts +8 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +69 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-storage.d.ts +54 -0
- package/dist/memory-storage.d.ts.map +1 -0
- package/dist/memory-storage.js +244 -0
- package/dist/memory-storage.js.map +1 -0
- package/dist/normalise.d.ts +5 -0
- package/dist/normalise.d.ts.map +1 -0
- package/dist/normalise.js +18 -0
- package/dist/normalise.js.map +1 -0
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +31 -0
- package/dist/permissions.js.map +1 -0
- package/dist/providers.d.ts +35 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +31 -0
- package/dist/providers.js.map +1 -0
- package/dist/rate-limit.d.ts +20 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +33 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +2 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|