@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 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 (works with Next.js, SvelteKit, Workers, Node servers).
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 `validateAuthorizationCode(...)` (Arctic-compatible).
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
- client: google,
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)`
@@ -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
- let user;
224
- if (email) {
225
- const existing = await this.config.adapters.users.findByEmail(email);
226
- if (existing) {
227
- user = await this.config.adapters.users.update(existing.id, merged);
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
- else {
230
- user = await this.config.adapters.users.create(merged);
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
- else {
234
- user = await this.config.adapters.users.create(merged);
235
- }
236
- const sessionId = await this.createSession(user.id);
237
- return successResult(user, sessionId);
238
- }
239
- async setActiveOrganization(sessionId, organizationId, request) {
240
- const session = await this.config.adapters.sessions.findById(sessionId);
241
- if (!session) {
242
- return errorOperation(new AuthError("SESSION_NOT_FOUND", "Session not found.", 404));
243
- }
244
- const organizationsApi = this.apiMap.get("organizations");
245
- if (!organizationsApi) {
246
- return errorOperation(new AuthError("METHOD_DISABLED", "organizations plugin is not enabled.", 400));
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
- const active = memberships.data.memberships.find((membership) => membership.organizationId === organizationId && membership.status === "active");
253
- if (!active) {
254
- return errorOperation(new AuthError("MEMBERSHIP_FORBIDDEN", "No active membership for organization.", 403));
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.config.adapters.sessions.setActiveOrganization(sessionId, organizationId);
257
- return successOperation({
258
- sessionId,
259
- activeOrganizationId: organizationId,
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) {
@@ -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>;
@@ -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: "1.0.0",
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, secretHash, secretVerify } from "../core/utils.js";
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 hashed = secretHash(token, "magic-link");
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: "1.0.0",
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 = secretHash(rawToken, "deterministic_magic_link");
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,
@@ -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
- client: ArcticAuthorizationCodeClient;
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>;