@oglofus/auth 1.0.0 → 1.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 +20 -6
- package/dist/core/auth.d.ts +5 -4
- package/dist/core/auth.js +219 -38
- package/dist/core/utils.d.ts +1 -0
- package/dist/core/utils.js +2 -1
- package/dist/plugins/email-otp.js +28 -4
- package/dist/plugins/magic-link.js +15 -18
- package/dist/plugins/oauth2.d.ts +8 -1
- package/dist/plugins/oauth2.js +65 -36
- package/dist/plugins/organizations.js +38 -6
- package/dist/plugins/passkey.js +20 -26
- package/dist/plugins/password.js +1 -1
- package/dist/plugins/two-factor.js +1 -4
- package/dist/types/adapters.d.ts +26 -22
- package/dist/types/model.d.ts +16 -13
- package/dist/types/plugins.d.ts +24 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ Type-safe, plugin-first authentication core for TypeScript applications.
|
|
|
8
8
|
- Strongly typed register/authenticate payloads inferred from enabled plugins.
|
|
9
9
|
- Path-based issue model (`{ message, path }`) for field-level error mapping.
|
|
10
10
|
- Built-in methods: password, email OTP, magic link, OAuth2, passkey.
|
|
11
|
-
- Built-in domain plugins: two-factor auth (TOTP/recovery), organizations/RBAC.
|
|
12
|
-
- Framework-agnostic core
|
|
11
|
+
- Built-in domain plugins: two-factor auth (TOTP/recovery code), organizations/RBAC.
|
|
12
|
+
- Framework-agnostic core for TypeScript apps running in Node-compatible server environments.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -87,10 +87,20 @@ const loggedIn = await auth.authenticate({
|
|
|
87
87
|
- `method(pluginMethod)` for plugin-specific APIs
|
|
88
88
|
- `verifySecondFactor(input, request?)`
|
|
89
89
|
- `completeProfile(input, request?)`
|
|
90
|
-
- `setActiveOrganization(sessionId, organizationId, request?)`
|
|
91
90
|
- `validateSession(sessionId, request?)`
|
|
92
91
|
- `signOut(sessionId, request?)`
|
|
93
92
|
|
|
93
|
+
Organization session switching is exposed by the organizations plugin API:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const orgApi = auth.method("organizations");
|
|
97
|
+
await orgApi.setActiveOrganization({
|
|
98
|
+
sessionId: "session_123",
|
|
99
|
+
organizationId: "org_123",
|
|
100
|
+
});
|
|
101
|
+
await orgApi.setActiveOrganization({ sessionId: "session_123" }); // clear
|
|
102
|
+
```
|
|
103
|
+
|
|
94
104
|
## Result and Error Shape
|
|
95
105
|
|
|
96
106
|
All operations return structured results.
|
|
@@ -154,19 +164,19 @@ createIssue("Generic failure");
|
|
|
154
164
|
### OAuth2 (Arctic)
|
|
155
165
|
|
|
156
166
|
- Method: `"oauth2"`
|
|
157
|
-
- Uses provider clients with `
|
|
167
|
+
- Uses provider exchange callbacks. Arctic clients can be wrapped with `arcticAuthorizationCodeExchange(...)`.
|
|
158
168
|
- Supports profile completion when required fields are missing.
|
|
159
169
|
|
|
160
170
|
```ts
|
|
161
171
|
import { Google } from "arctic";
|
|
162
|
-
import { oauth2Plugin } from "@oglofus/auth";
|
|
172
|
+
import { arcticAuthorizationCodeExchange, oauth2Plugin } from "@oglofus/auth";
|
|
163
173
|
|
|
164
174
|
const google = new Google(process.env.GOOGLE_CLIENT_ID!, process.env.GOOGLE_CLIENT_SECRET!, process.env.GOOGLE_REDIRECT_URI!);
|
|
165
175
|
|
|
166
176
|
oauth2Plugin<AppUser, "google", "given_name" | "family_name">({
|
|
167
177
|
providers: {
|
|
168
178
|
google: {
|
|
169
|
-
|
|
179
|
+
exchangeAuthorizationCode: arcticAuthorizationCodeExchange(google),
|
|
170
180
|
resolveProfile: async ({ tokens }) => {
|
|
171
181
|
const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
|
|
172
182
|
headers: { Authorization: `Bearer ${tokens.accessToken()}` },
|
|
@@ -201,6 +211,7 @@ const result = await auth.authenticate({
|
|
|
201
211
|
authorizationCode: "code-from-callback",
|
|
202
212
|
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
|
|
203
213
|
codeVerifier: "pkce-code-verifier",
|
|
214
|
+
idempotencyKey: "oauth-state",
|
|
204
215
|
});
|
|
205
216
|
```
|
|
206
217
|
|
|
@@ -208,12 +219,15 @@ const result = await auth.authenticate({
|
|
|
208
219
|
|
|
209
220
|
- Method: `"passkey"`
|
|
210
221
|
- Register + authenticate supported.
|
|
222
|
+
- The package consumes already-verified passkey results; it does not perform raw WebAuthn attestation/assertion verification.
|
|
223
|
+
- Verify WebAuthn with `@simplewebauthn/server` or equivalent first, then pass the verified result into `auth.register(...)` / `auth.authenticate(...)`.
|
|
211
224
|
- Config: `requiredProfileFields`, `passkeys` adapter.
|
|
212
225
|
|
|
213
226
|
### Two-Factor (Domain Plugin)
|
|
214
227
|
|
|
215
228
|
- Method: `"two_factor"`
|
|
216
229
|
- Adds post-primary verification (`TWO_FACTOR_REQUIRED`).
|
|
230
|
+
- This release supports `totp` and `recovery_code`.
|
|
217
231
|
- Uses `@oslojs/otp` internally for TOTP verification and enrollment URI generation.
|
|
218
232
|
- Plugin API:
|
|
219
233
|
- `beginTotpEnrollment(userId)`
|
package/dist/core/auth.d.ts
CHANGED
|
@@ -13,10 +13,6 @@ export declare class OglofusAuth<U extends UserBase, P extends readonly AnyPlugi
|
|
|
13
13
|
method<M extends PluginMethodsWithApi<P>>(method: M): PluginApiMap<P>[M];
|
|
14
14
|
verifySecondFactor(input: TwoFactorVerifyInput, request?: AuthRequestContext): Promise<AuthResult<U>>;
|
|
15
15
|
completeProfile(input: CompleteProfileInput<U>, request?: AuthRequestContext): Promise<AuthResult<U>>;
|
|
16
|
-
setActiveOrganization(sessionId: string, organizationId: string, request?: AuthRequestContext): Promise<OperationResult<{
|
|
17
|
-
sessionId: string;
|
|
18
|
-
activeOrganizationId: string;
|
|
19
|
-
}>>;
|
|
20
16
|
validateSession(sessionId: string, _request?: AuthRequestContext): Promise<{
|
|
21
17
|
ok: true;
|
|
22
18
|
userId: string;
|
|
@@ -25,10 +21,15 @@ export declare class OglofusAuth<U extends UserBase, P extends readonly AnyPlugi
|
|
|
25
21
|
}>;
|
|
26
22
|
signOut(sessionId: string, request?: AuthRequestContext): Promise<void>;
|
|
27
23
|
private normalizeAuthInput;
|
|
24
|
+
private persistCompletedProfile;
|
|
28
25
|
private makeContext;
|
|
29
26
|
private createSession;
|
|
30
27
|
private getAuthMethodPlugin;
|
|
31
28
|
private tryGetTwoFactorApi;
|
|
29
|
+
private resolveRateLimitPolicy;
|
|
30
|
+
private consumeRateLimit;
|
|
31
|
+
private makeRateLimitKey;
|
|
32
|
+
private getRateLimitIdentity;
|
|
32
33
|
private runValidator;
|
|
33
34
|
private validatePlugins;
|
|
34
35
|
private assertAcyclicRoleInheritance;
|
package/dist/core/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthError } from "../errors/index.js";
|
|
2
2
|
import { errorOperation, errorResult, successOperation, successResult, } from "../types/results.js";
|
|
3
|
-
import { DEFAULT_SESSION_TTL_SECONDS, addSeconds, createId, normalizeEmailDefault, now, } from "./utils.js";
|
|
3
|
+
import { DEFAULT_SESSION_TTL_SECONDS, addSeconds, createId, deterministicTokenHash, normalizeEmailDefault, now, } from "./utils.js";
|
|
4
4
|
import { createIssue } from "../issues/index.js";
|
|
5
5
|
const hasEmail = (value) => typeof value === "object" && value !== null && "email" in value && typeof value.email === "string";
|
|
6
6
|
const toAuthError = (error) => {
|
|
@@ -9,6 +9,14 @@ const toAuthError = (error) => {
|
|
|
9
9
|
}
|
|
10
10
|
return new AuthError("INTERNAL_ERROR", "Internal error.", 500);
|
|
11
11
|
};
|
|
12
|
+
const DEFAULT_RATE_LIMIT_POLICIES = {
|
|
13
|
+
discover: { limit: 10, windowSeconds: 60 },
|
|
14
|
+
register: { limit: 5, windowSeconds: 300 },
|
|
15
|
+
authenticate: { limit: 10, windowSeconds: 300 },
|
|
16
|
+
emailOtpRequest: { limit: 3, windowSeconds: 300 },
|
|
17
|
+
magicLinkRequest: { limit: 3, windowSeconds: 300 },
|
|
18
|
+
otpVerify: { limit: 10, windowSeconds: 300 },
|
|
19
|
+
};
|
|
12
20
|
export class OglofusAuth {
|
|
13
21
|
config;
|
|
14
22
|
pluginMap = new Map();
|
|
@@ -24,12 +32,17 @@ export class OglofusAuth {
|
|
|
24
32
|
this.apiMap.set(plugin.method, plugin.createApi({
|
|
25
33
|
adapters: this.config.adapters,
|
|
26
34
|
now,
|
|
35
|
+
security: this.config.security,
|
|
27
36
|
}));
|
|
28
37
|
}
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
async discover(input, request) {
|
|
32
41
|
const email = this.normalizeEmail(input.email);
|
|
42
|
+
const rateLimitError = await this.consumeRateLimit("discover", email, request);
|
|
43
|
+
if (rateLimitError) {
|
|
44
|
+
return errorOperation(rateLimitError);
|
|
45
|
+
}
|
|
33
46
|
const mode = this.config.accountDiscovery?.mode ?? "private";
|
|
34
47
|
if (mode === "private") {
|
|
35
48
|
return successOperation({
|
|
@@ -83,6 +96,17 @@ export class OglofusAuth {
|
|
|
83
96
|
return errorResult(new AuthError("METHOD_DISABLED", `Method ${method} is disabled.`, 400));
|
|
84
97
|
}
|
|
85
98
|
const normalized = this.normalizeAuthInput(input);
|
|
99
|
+
const rateLimitError = await this.consumeRateLimit("authenticate", this.getRateLimitIdentity(normalized), request);
|
|
100
|
+
if (rateLimitError) {
|
|
101
|
+
await this.writeAudit({
|
|
102
|
+
action: "authenticate",
|
|
103
|
+
method,
|
|
104
|
+
requestId: request?.requestId,
|
|
105
|
+
success: false,
|
|
106
|
+
errorCode: rateLimitError.code,
|
|
107
|
+
});
|
|
108
|
+
return errorResult(rateLimitError);
|
|
109
|
+
}
|
|
86
110
|
const validated = this.runValidator(plugin.validators?.authenticate, normalized);
|
|
87
111
|
if (!validated.ok) {
|
|
88
112
|
return validated.result;
|
|
@@ -145,6 +169,17 @@ export class OglofusAuth {
|
|
|
145
169
|
return errorResult(new AuthError("METHOD_NOT_REGISTERABLE", `Method ${method} does not support register.`, 400));
|
|
146
170
|
}
|
|
147
171
|
const normalized = this.normalizeAuthInput(input);
|
|
172
|
+
const rateLimitError = await this.consumeRateLimit("register", this.getRateLimitIdentity(normalized), request);
|
|
173
|
+
if (rateLimitError) {
|
|
174
|
+
await this.writeAudit({
|
|
175
|
+
action: "register",
|
|
176
|
+
method,
|
|
177
|
+
requestId: request?.requestId,
|
|
178
|
+
success: false,
|
|
179
|
+
errorCode: rateLimitError.code,
|
|
180
|
+
});
|
|
181
|
+
return errorResult(rateLimitError);
|
|
182
|
+
}
|
|
148
183
|
const validated = this.runValidator(plugin.validators?.register, normalized);
|
|
149
184
|
if (!validated.ok) {
|
|
150
185
|
return validated.result;
|
|
@@ -195,6 +230,16 @@ export class OglofusAuth {
|
|
|
195
230
|
}
|
|
196
231
|
const record = await pending.findById(input.pendingProfileId);
|
|
197
232
|
if (!record || record.consumedAt || record.expiresAt.getTime() <= now().getTime()) {
|
|
233
|
+
await this.writeAudit({
|
|
234
|
+
action: "complete_profile",
|
|
235
|
+
method: "complete_profile",
|
|
236
|
+
requestId: request?.requestId,
|
|
237
|
+
success: false,
|
|
238
|
+
errorCode: "PROFILE_COMPLETION_EXPIRED",
|
|
239
|
+
meta: {
|
|
240
|
+
pendingProfileId: input.pendingProfileId,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
198
243
|
return errorResult(new AuthError("PROFILE_COMPLETION_EXPIRED", "Profile completion has expired.", 400));
|
|
199
244
|
}
|
|
200
245
|
const missingIssues = [];
|
|
@@ -205,12 +250,18 @@ export class OglofusAuth {
|
|
|
205
250
|
}
|
|
206
251
|
}
|
|
207
252
|
if (missingIssues.length > 0) {
|
|
253
|
+
await this.writeAudit({
|
|
254
|
+
action: "complete_profile",
|
|
255
|
+
method: record.sourceMethod,
|
|
256
|
+
requestId: request?.requestId,
|
|
257
|
+
success: false,
|
|
258
|
+
errorCode: "INVALID_INPUT",
|
|
259
|
+
meta: {
|
|
260
|
+
pendingProfileId: input.pendingProfileId,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
208
263
|
return errorResult(new AuthError("INVALID_INPUT", "Missing required profile fields.", 400, missingIssues), missingIssues);
|
|
209
264
|
}
|
|
210
|
-
const consumed = await pending.consume(input.pendingProfileId);
|
|
211
|
-
if (!consumed) {
|
|
212
|
-
return errorResult(new AuthError("PROFILE_COMPLETION_EXPIRED", "Profile completion has expired.", 400));
|
|
213
|
-
}
|
|
214
265
|
const email = record.email ? this.normalizeEmail(record.email) : undefined;
|
|
215
266
|
const merged = {
|
|
216
267
|
...record.prefill,
|
|
@@ -220,44 +271,94 @@ export class OglofusAuth {
|
|
|
220
271
|
if (merged.emailVerified === undefined) {
|
|
221
272
|
merged.emailVerified = false;
|
|
222
273
|
}
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
274
|
+
const plugin = this.getAuthMethodPlugin(record.sourceMethod);
|
|
275
|
+
if (record.continuation && (!plugin || !plugin.completePendingProfile)) {
|
|
276
|
+
const error = new AuthError("PLUGIN_MISCONFIGURED", `Pending profile for '${record.sourceMethod}' cannot be completed by the current plugin set.`, 500);
|
|
277
|
+
await this.writeAudit({
|
|
278
|
+
action: "complete_profile",
|
|
279
|
+
method: record.sourceMethod,
|
|
280
|
+
requestId: request?.requestId,
|
|
281
|
+
success: false,
|
|
282
|
+
errorCode: error.code,
|
|
283
|
+
meta: {
|
|
284
|
+
pendingProfileId: input.pendingProfileId,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
return errorResult(error);
|
|
288
|
+
}
|
|
289
|
+
const ctx = this.makeContext(request);
|
|
290
|
+
let finalizedUserId;
|
|
291
|
+
const finalize = async () => {
|
|
292
|
+
const persisted = await this.persistCompletedProfile(email, merged);
|
|
293
|
+
if (!persisted.ok) {
|
|
294
|
+
return persisted;
|
|
228
295
|
}
|
|
229
|
-
|
|
230
|
-
|
|
296
|
+
finalizedUserId = persisted.user.id;
|
|
297
|
+
if (plugin?.completePendingProfile) {
|
|
298
|
+
const continuation = await plugin.completePendingProfile(ctx, {
|
|
299
|
+
record,
|
|
300
|
+
user: persisted.user,
|
|
301
|
+
});
|
|
302
|
+
if (!continuation.ok) {
|
|
303
|
+
return { ok: false, error: continuation.error };
|
|
304
|
+
}
|
|
231
305
|
}
|
|
306
|
+
const consumed = await pending.consume(input.pendingProfileId);
|
|
307
|
+
if (!consumed) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
error: new AuthError("PROFILE_COMPLETION_EXPIRED", "Profile completion has expired.", 400),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return { ok: true, user: persisted.user };
|
|
314
|
+
};
|
|
315
|
+
let finalized;
|
|
316
|
+
try {
|
|
317
|
+
finalized = this.config.adapters.withTransaction
|
|
318
|
+
? await this.config.adapters.withTransaction(finalize)
|
|
319
|
+
: await finalize();
|
|
232
320
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
247
|
-
}
|
|
248
|
-
const memberships = await organizationsApi.listMemberships({ userId: session.userId }, request);
|
|
249
|
-
if (!memberships.ok) {
|
|
250
|
-
return memberships;
|
|
321
|
+
catch (error) {
|
|
322
|
+
const authError = toAuthError(error);
|
|
323
|
+
await this.writeAudit({
|
|
324
|
+
action: "complete_profile",
|
|
325
|
+
method: record.sourceMethod,
|
|
326
|
+
requestId: request?.requestId,
|
|
327
|
+
success: false,
|
|
328
|
+
errorCode: authError.code,
|
|
329
|
+
userId: finalizedUserId,
|
|
330
|
+
meta: {
|
|
331
|
+
pendingProfileId: input.pendingProfileId,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
return errorResult(authError);
|
|
251
335
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
336
|
+
if (!finalized.ok) {
|
|
337
|
+
await this.writeAudit({
|
|
338
|
+
action: "complete_profile",
|
|
339
|
+
method: record.sourceMethod,
|
|
340
|
+
requestId: request?.requestId,
|
|
341
|
+
success: false,
|
|
342
|
+
errorCode: finalized.error.code,
|
|
343
|
+
userId: finalizedUserId,
|
|
344
|
+
meta: {
|
|
345
|
+
pendingProfileId: input.pendingProfileId,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
return errorResult(finalized.error);
|
|
255
349
|
}
|
|
256
|
-
await this.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
350
|
+
const sessionId = await this.createSession(finalized.user.id);
|
|
351
|
+
await this.writeAudit({
|
|
352
|
+
action: "complete_profile",
|
|
353
|
+
method: record.sourceMethod,
|
|
354
|
+
requestId: request?.requestId,
|
|
355
|
+
success: true,
|
|
356
|
+
userId: finalized.user.id,
|
|
357
|
+
meta: {
|
|
358
|
+
pendingProfileId: input.pendingProfileId,
|
|
359
|
+
},
|
|
260
360
|
});
|
|
361
|
+
return successResult(finalized.user, sessionId);
|
|
261
362
|
}
|
|
262
363
|
async validateSession(sessionId, _request) {
|
|
263
364
|
const session = await this.config.adapters.sessions.findById(sessionId);
|
|
@@ -289,10 +390,25 @@ export class OglofusAuth {
|
|
|
289
390
|
email: this.normalizeEmail(input.email),
|
|
290
391
|
};
|
|
291
392
|
}
|
|
393
|
+
async persistCompletedProfile(email, merged) {
|
|
394
|
+
if (email) {
|
|
395
|
+
const existing = await this.config.adapters.users.findByEmail(email);
|
|
396
|
+
if (existing) {
|
|
397
|
+
const updated = await this.config.adapters.users.update(existing.id, merged);
|
|
398
|
+
if (!updated) {
|
|
399
|
+
return { ok: false, error: new AuthError("USER_NOT_FOUND", "User not found.", 404) };
|
|
400
|
+
}
|
|
401
|
+
return { ok: true, user: updated };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const created = await this.config.adapters.users.create(merged);
|
|
405
|
+
return { ok: true, user: created };
|
|
406
|
+
}
|
|
292
407
|
makeContext(request) {
|
|
293
408
|
return {
|
|
294
409
|
adapters: this.config.adapters,
|
|
295
410
|
now,
|
|
411
|
+
security: this.config.security,
|
|
296
412
|
request,
|
|
297
413
|
};
|
|
298
414
|
}
|
|
@@ -322,6 +438,68 @@ export class OglofusAuth {
|
|
|
322
438
|
}
|
|
323
439
|
return api;
|
|
324
440
|
}
|
|
441
|
+
resolveRateLimitPolicy(scope) {
|
|
442
|
+
if (!this.config.adapters.rateLimiter) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
return this.config.security?.rateLimits?.[scope] ?? DEFAULT_RATE_LIMIT_POLICIES[scope];
|
|
446
|
+
}
|
|
447
|
+
async consumeRateLimit(scope, identity, request) {
|
|
448
|
+
const policy = this.resolveRateLimitPolicy(scope);
|
|
449
|
+
if (!policy || !this.config.adapters.rateLimiter) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const result = await this.config.adapters.rateLimiter.consume(this.makeRateLimitKey(scope, identity, request), policy.limit, policy.windowSeconds);
|
|
453
|
+
if (result.allowed) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
return new AuthError("RATE_LIMITED", "Too many requests.", 429, [], result.retryAfterSeconds === undefined
|
|
457
|
+
? undefined
|
|
458
|
+
: { retryAfterSeconds: result.retryAfterSeconds });
|
|
459
|
+
}
|
|
460
|
+
makeRateLimitKey(scope, identity, request) {
|
|
461
|
+
if (!request?.ip) {
|
|
462
|
+
return `${scope}:identity:${identity}`;
|
|
463
|
+
}
|
|
464
|
+
return `${scope}:ip:${request.ip}:identity:${identity}`;
|
|
465
|
+
}
|
|
466
|
+
getRateLimitIdentity(input) {
|
|
467
|
+
if (hasEmail(input)) {
|
|
468
|
+
return this.normalizeEmail(input.email);
|
|
469
|
+
}
|
|
470
|
+
if (!input || typeof input !== "object") {
|
|
471
|
+
return "anonymous";
|
|
472
|
+
}
|
|
473
|
+
const record = input;
|
|
474
|
+
if (typeof record.challengeId === "string" && record.challengeId.length > 0) {
|
|
475
|
+
return `challenge:${record.challengeId}`;
|
|
476
|
+
}
|
|
477
|
+
if (typeof record.pendingAuthId === "string" && record.pendingAuthId.length > 0) {
|
|
478
|
+
return `pending:${record.pendingAuthId}`;
|
|
479
|
+
}
|
|
480
|
+
if (typeof record.token === "string" && record.token.length > 0) {
|
|
481
|
+
return `token:${deterministicTokenHash(record.token, "rate-limit:magic-link")}`;
|
|
482
|
+
}
|
|
483
|
+
if (typeof record.provider === "string" && record.provider.length > 0) {
|
|
484
|
+
return `provider:${record.provider}`;
|
|
485
|
+
}
|
|
486
|
+
const authentication = record.authentication;
|
|
487
|
+
if (authentication &&
|
|
488
|
+
typeof authentication === "object" &&
|
|
489
|
+
typeof authentication.credentialId === "string") {
|
|
490
|
+
return `credential:${String(authentication.credentialId)}`;
|
|
491
|
+
}
|
|
492
|
+
const registration = record.registration;
|
|
493
|
+
if (registration &&
|
|
494
|
+
typeof registration === "object" &&
|
|
495
|
+
typeof registration.credentialId === "string") {
|
|
496
|
+
return `credential:${String(registration.credentialId)}`;
|
|
497
|
+
}
|
|
498
|
+
if (typeof record.method === "string" && record.method.length > 0) {
|
|
499
|
+
return `method:${record.method}`;
|
|
500
|
+
}
|
|
501
|
+
return "anonymous";
|
|
502
|
+
}
|
|
325
503
|
runValidator(validator, input) {
|
|
326
504
|
if (!validator) {
|
|
327
505
|
return { ok: true, input: input };
|
|
@@ -378,8 +556,11 @@ export class OglofusAuth {
|
|
|
378
556
|
if ((this.config.accountDiscovery?.mode ?? "private") === "explicit" && !this.config.adapters.identity) {
|
|
379
557
|
throw new AuthError("PLUGIN_MISCONFIGURED", "identity adapter is required when accountDiscovery.mode is explicit.", 500);
|
|
380
558
|
}
|
|
559
|
+
const orgPlugin = this.config.plugins.find((plugin) => plugin.method === "organizations");
|
|
560
|
+
if (orgPlugin && !orgPlugin.__organizationConfig?.handlers?.organizationSessions) {
|
|
561
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", "organizations plugin requires handlers.organizationSessions.", 500);
|
|
562
|
+
}
|
|
381
563
|
if (this.config.validateConfigOnStart) {
|
|
382
|
-
const orgPlugin = this.config.plugins.find((plugin) => plugin.method === "organizations");
|
|
383
564
|
const roleConfig = orgPlugin?.__organizationConfig?.handlers?.roles;
|
|
384
565
|
const defaultRole = orgPlugin?.__organizationConfig?.handlers?.defaultRole;
|
|
385
566
|
if (roleConfig && defaultRole) {
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare const createId: () => string;
|
|
|
6
6
|
export declare const createToken: (size?: number) => string;
|
|
7
7
|
export declare const createNumericCode: (length?: number) => string;
|
|
8
8
|
export declare const secretHash: (value: string, salt?: string) => string;
|
|
9
|
+
export declare const deterministicTokenHash: (value: string, namespace: string) => string;
|
|
9
10
|
export declare const secretVerify: (value: string, encoded: string) => boolean;
|
|
10
11
|
export declare const cloneWithout: <T extends Record<string, unknown>, K extends readonly (keyof T)[]>(input: T, keys: K) => Omit<T, K[number]>;
|
|
11
12
|
export declare const ensureRecord: (value: unknown) => value is Record<string, unknown>;
|
package/dist/core/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pbkdf2Sync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, pbkdf2Sync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
export const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
3
3
|
export const normalizeEmailDefault = (value) => value.trim().toLowerCase();
|
|
4
4
|
export const now = () => new Date();
|
|
@@ -18,6 +18,7 @@ export const secretHash = (value, salt = randomBytes(16).toString("hex")) => {
|
|
|
18
18
|
const derived = pbkdf2Sync(value, salt, 120_000, 32, "sha256").toString("hex");
|
|
19
19
|
return `${salt}:${derived}`;
|
|
20
20
|
};
|
|
21
|
+
export const deterministicTokenHash = (value, namespace) => createHash("sha256").update(namespace).update(":").update(value).digest("hex");
|
|
21
22
|
export const secretVerify = (value, encoded) => {
|
|
22
23
|
const [salt, hash] = encoded.split(":");
|
|
23
24
|
if (!salt || !hash) {
|
|
@@ -5,13 +5,28 @@ import { addSeconds, createId, createNumericCode, secretHash, secretVerify, } fr
|
|
|
5
5
|
import { cloneWithout } from "../core/utils.js";
|
|
6
6
|
import { ensureFields } from "../core/validators.js";
|
|
7
7
|
const PENDING_PREFIX = "pending:";
|
|
8
|
+
const EMAIL_OTP_REQUEST_POLICY = { limit: 3, windowSeconds: 300 };
|
|
9
|
+
const OTP_VERIFY_POLICY = { limit: 10, windowSeconds: 300 };
|
|
8
10
|
const isPendingUserId = (value) => value.startsWith(PENDING_PREFIX);
|
|
11
|
+
const createRateLimitedError = (retryAfterSeconds) => new AuthError("RATE_LIMITED", "Too many requests.", 429, [], retryAfterSeconds === undefined ? undefined : { retryAfterSeconds });
|
|
9
12
|
export const emailOtpPlugin = (config) => {
|
|
10
13
|
const ttl = config.challengeTtlSeconds ?? 10 * 60;
|
|
11
14
|
const maxAttempts = config.maxAttempts ?? 5;
|
|
12
15
|
const codeLength = config.codeLength ?? 6;
|
|
13
|
-
const verifyChallenge = async (challengeId, code, now) => {
|
|
16
|
+
const verifyChallenge = async (challengeId, code, now, request, security, rateLimiter) => {
|
|
14
17
|
const challenge = await config.otp.findChallengeById(challengeId);
|
|
18
|
+
if (rateLimiter) {
|
|
19
|
+
const policy = security?.rateLimits?.otpVerify ?? OTP_VERIFY_POLICY;
|
|
20
|
+
const result = await rateLimiter.consume(request?.ip
|
|
21
|
+
? `otpVerify:ip:${request.ip}:identity:${challenge?.email ?? `challenge:${challengeId}`}`
|
|
22
|
+
: `otpVerify:identity:${challenge?.email ?? `challenge:${challengeId}`}`, policy.limit, policy.windowSeconds);
|
|
23
|
+
if (!result.allowed) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: createRateLimitedError(result.retryAfterSeconds),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
15
30
|
if (!challenge) {
|
|
16
31
|
return {
|
|
17
32
|
ok: false,
|
|
@@ -68,7 +83,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
68
83
|
return {
|
|
69
84
|
kind: "auth_method",
|
|
70
85
|
method: "email_otp",
|
|
71
|
-
version: "
|
|
86
|
+
version: "2.0.0",
|
|
72
87
|
supports: {
|
|
73
88
|
register: true,
|
|
74
89
|
},
|
|
@@ -79,6 +94,15 @@ export const emailOtpPlugin = (config) => {
|
|
|
79
94
|
createApi: (ctx) => ({
|
|
80
95
|
request: async (input, request) => {
|
|
81
96
|
const email = input.email.trim().toLowerCase();
|
|
97
|
+
if (ctx.adapters.rateLimiter) {
|
|
98
|
+
const policy = ctx.security?.rateLimits?.emailOtpRequest ?? EMAIL_OTP_REQUEST_POLICY;
|
|
99
|
+
const limited = await ctx.adapters.rateLimiter.consume(request?.ip
|
|
100
|
+
? `emailOtpRequest:ip:${request.ip}:identity:${email}`
|
|
101
|
+
: `emailOtpRequest:identity:${email}`, policy.limit, policy.windowSeconds);
|
|
102
|
+
if (!limited.allowed) {
|
|
103
|
+
return errorOperation(createRateLimitedError(limited.retryAfterSeconds));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
82
106
|
const user = await ctx.adapters.users.findByEmail(email);
|
|
83
107
|
const code = createNumericCode(codeLength);
|
|
84
108
|
const challenge = await config.otp.createChallenge({
|
|
@@ -116,7 +140,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
116
140
|
},
|
|
117
141
|
}),
|
|
118
142
|
register: async (ctx, input) => {
|
|
119
|
-
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now());
|
|
143
|
+
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now(), ctx.request, ctx.security, ctx.adapters.rateLimiter);
|
|
120
144
|
if (!verified.ok) {
|
|
121
145
|
return errorOperation(verified.error);
|
|
122
146
|
}
|
|
@@ -141,7 +165,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
141
165
|
return successOperation({ user });
|
|
142
166
|
},
|
|
143
167
|
authenticate: async (ctx, input) => {
|
|
144
|
-
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now());
|
|
168
|
+
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now(), ctx.request, ctx.security, ctx.adapters.rateLimiter);
|
|
145
169
|
if (!verified.ok) {
|
|
146
170
|
return errorOperation(verified.error);
|
|
147
171
|
}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { AuthError } from "../errors/index.js";
|
|
2
2
|
import { createIssue } from "../issues/index.js";
|
|
3
3
|
import { errorOperation, successOperation } from "../types/results.js";
|
|
4
|
-
import { addSeconds, createId, createToken,
|
|
4
|
+
import { addSeconds, createId, createToken, deterministicTokenHash } from "../core/utils.js";
|
|
5
5
|
import { cloneWithout } from "../core/utils.js";
|
|
6
6
|
import { ensureFields } from "../core/validators.js";
|
|
7
|
+
const MAGIC_LINK_REQUEST_POLICY = { limit: 3, windowSeconds: 300 };
|
|
8
|
+
const createRateLimitedError = (retryAfterSeconds) => new AuthError("RATE_LIMITED", "Too many requests.", 429, [], retryAfterSeconds === undefined ? undefined : { retryAfterSeconds });
|
|
7
9
|
export const magicLinkPlugin = (config) => {
|
|
8
10
|
const ttl = config.tokenTtlSeconds ?? 15 * 60;
|
|
9
11
|
const verifyToken = async (token, now) => {
|
|
10
|
-
const
|
|
11
|
-
// `secretHash` is salted; this branch should use deterministic hash.
|
|
12
|
-
// We keep compatibility by also trying deterministic fallback below.
|
|
13
|
-
const deterministicHash = secretHash(token, "deterministic_magic_link");
|
|
14
|
-
const candidate = (await config.links.findActiveTokenByHash(deterministicHash)) ??
|
|
15
|
-
(await config.links.findActiveTokenByHash(hashed));
|
|
12
|
+
const candidate = await config.links.findActiveTokenByHash(deterministicTokenHash(token, "magic_link"));
|
|
16
13
|
if (!candidate) {
|
|
17
14
|
return {
|
|
18
15
|
ok: false,
|
|
@@ -21,15 +18,6 @@ export const magicLinkPlugin = (config) => {
|
|
|
21
18
|
]),
|
|
22
19
|
};
|
|
23
20
|
}
|
|
24
|
-
// Defensive verify for deterministic hash implementations.
|
|
25
|
-
if (!secretVerify(token, candidate.tokenHash) && candidate.tokenHash !== deterministicHash) {
|
|
26
|
-
return {
|
|
27
|
-
ok: false,
|
|
28
|
-
error: new AuthError("MAGIC_LINK_INVALID", "Invalid magic link.", 400, [
|
|
29
|
-
createIssue("Invalid token", ["token"]),
|
|
30
|
-
]),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
21
|
if (candidate.expiresAt.getTime() <= now.getTime()) {
|
|
34
22
|
return {
|
|
35
23
|
ok: false,
|
|
@@ -55,7 +43,7 @@ export const magicLinkPlugin = (config) => {
|
|
|
55
43
|
return {
|
|
56
44
|
kind: "auth_method",
|
|
57
45
|
method: "magic_link",
|
|
58
|
-
version: "
|
|
46
|
+
version: "2.0.0",
|
|
59
47
|
supports: {
|
|
60
48
|
register: true,
|
|
61
49
|
},
|
|
@@ -66,9 +54,18 @@ export const magicLinkPlugin = (config) => {
|
|
|
66
54
|
createApi: (ctx) => ({
|
|
67
55
|
request: async (input, request) => {
|
|
68
56
|
const email = input.email.trim().toLowerCase();
|
|
57
|
+
if (ctx.adapters.rateLimiter) {
|
|
58
|
+
const policy = ctx.security?.rateLimits?.magicLinkRequest ?? MAGIC_LINK_REQUEST_POLICY;
|
|
59
|
+
const limited = await ctx.adapters.rateLimiter.consume(request?.ip
|
|
60
|
+
? `magicLinkRequest:ip:${request.ip}:identity:${email}`
|
|
61
|
+
: `magicLinkRequest:identity:${email}`, policy.limit, policy.windowSeconds);
|
|
62
|
+
if (!limited.allowed) {
|
|
63
|
+
return errorOperation(createRateLimitedError(limited.retryAfterSeconds));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
69
66
|
const user = await ctx.adapters.users.findByEmail(email);
|
|
70
67
|
const rawToken = createToken();
|
|
71
|
-
const tokenHash =
|
|
68
|
+
const tokenHash = deterministicTokenHash(rawToken, "magic_link");
|
|
72
69
|
const expiresAt = addSeconds(ctx.now(), ttl);
|
|
73
70
|
const token = await config.links.createToken({
|
|
74
71
|
userId: user?.id,
|
package/dist/plugins/oauth2.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export type ArcticAuthorizationCodeClient = {
|
|
|
14
14
|
} | {
|
|
15
15
|
validateAuthorizationCode(code: string, codeVerifier: string | null): Promise<OAuth2Tokens>;
|
|
16
16
|
};
|
|
17
|
+
export type OAuth2AuthorizationCodeExchangeInput = {
|
|
18
|
+
authorizationCode: string;
|
|
19
|
+
codeVerifier?: string;
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
};
|
|
22
|
+
export type OAuth2AuthorizationCodeExchange = (input: OAuth2AuthorizationCodeExchangeInput) => Promise<OAuth2Tokens>;
|
|
17
23
|
export type OAuth2ResolvedProfile<U extends UserBase, P extends string> = {
|
|
18
24
|
provider?: P;
|
|
19
25
|
providerUserId: string;
|
|
@@ -22,7 +28,7 @@ export type OAuth2ResolvedProfile<U extends UserBase, P extends string> = {
|
|
|
22
28
|
profile?: Partial<U>;
|
|
23
29
|
};
|
|
24
30
|
export type OAuth2ProviderConfig<U extends UserBase, P extends string> = {
|
|
25
|
-
|
|
31
|
+
exchangeAuthorizationCode: OAuth2AuthorizationCodeExchange;
|
|
26
32
|
resolveProfile: (input: {
|
|
27
33
|
provider: P;
|
|
28
34
|
tokens: OAuth2Tokens;
|
|
@@ -38,4 +44,5 @@ export type OAuth2PluginConfig<U extends UserBase, P extends string, K extends k
|
|
|
38
44
|
requiredProfileFields?: readonly K[];
|
|
39
45
|
pendingProfileTtlSeconds?: number;
|
|
40
46
|
};
|
|
47
|
+
export declare const arcticAuthorizationCodeExchange: (client: ArcticAuthorizationCodeClient) => OAuth2AuthorizationCodeExchange;
|
|
41
48
|
export declare const oauth2Plugin: <U extends UserBase, P extends string, K extends keyof U = never>(config: OAuth2PluginConfig<U, P, K>) => AuthMethodPlugin<"oauth2", OAuth2AuthenticateInput<P>, OAuth2AuthenticateInput<P>, U>;
|