@oglofus/auth 1.0.1 → 1.1.1

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
 
@@ -29,6 +29,7 @@ Optional for app-level integrations:
29
29
 
30
30
  - `arctic` for OAuth providers in your app code.
31
31
  - `@oslojs/otp` if you need direct OTP utilities in your app (the library already uses it internally for TOTP).
32
+ - `stripe` if you use the Stripe billing plugin.
32
33
 
33
34
  ## Quick Start (Password)
34
35
 
@@ -87,10 +88,20 @@ const loggedIn = await auth.authenticate({
87
88
  - `method(pluginMethod)` for plugin-specific APIs
88
89
  - `verifySecondFactor(input, request?)`
89
90
  - `completeProfile(input, request?)`
90
- - `setActiveOrganization(sessionId, organizationId, request?)`
91
91
  - `validateSession(sessionId, request?)`
92
92
  - `signOut(sessionId, request?)`
93
93
 
94
+ Organization session switching is exposed by the organizations plugin API:
95
+
96
+ ```ts
97
+ const orgApi = auth.method("organizations");
98
+ await orgApi.setActiveOrganization({
99
+ sessionId: "session_123",
100
+ organizationId: "org_123",
101
+ });
102
+ await orgApi.setActiveOrganization({ sessionId: "session_123" }); // clear
103
+ ```
104
+
94
105
  ## Result and Error Shape
95
106
 
96
107
  All operations return structured results.
@@ -117,15 +128,9 @@ You can build issues with helpers:
117
128
  ```ts
118
129
  import { createIssue, createIssueFactory } from "@oglofus/auth";
119
130
 
120
- const issue = createIssueFactory<{ email: string; profile: unknown }>([
121
- "email",
122
- "profile",
123
- ] as const);
131
+ const issue = createIssueFactory<{ email: string; profile: unknown }>(["email", "profile"] as const);
124
132
  issue.email("Email is required");
125
- issue.$path(
126
- ["profile", { key: "addresses" }, { index: 0 }, "city"],
127
- "City is required",
128
- );
133
+ issue.$path(["profile", { key: "addresses" }, { index: 0 }, "city"], "City is required");
129
134
  createIssue("Generic failure");
130
135
  ```
131
136
 
@@ -154,19 +159,19 @@ createIssue("Generic failure");
154
159
  ### OAuth2 (Arctic)
155
160
 
156
161
  - Method: `"oauth2"`
157
- - Uses provider clients with `validateAuthorizationCode(...)` (Arctic-compatible).
162
+ - Uses provider exchange callbacks. Arctic clients can be wrapped with `arcticAuthorizationCodeExchange(...)`.
158
163
  - Supports profile completion when required fields are missing.
159
164
 
160
165
  ```ts
161
166
  import { Google } from "arctic";
162
- import { oauth2Plugin } from "@oglofus/auth";
167
+ import { arcticAuthorizationCodeExchange, oauth2Plugin } from "@oglofus/auth";
163
168
 
164
169
  const google = new Google(process.env.GOOGLE_CLIENT_ID!, process.env.GOOGLE_CLIENT_SECRET!, process.env.GOOGLE_REDIRECT_URI!);
165
170
 
166
171
  oauth2Plugin<AppUser, "google", "given_name" | "family_name">({
167
172
  providers: {
168
173
  google: {
169
- client: google,
174
+ exchangeAuthorizationCode: arcticAuthorizationCodeExchange(google),
170
175
  resolveProfile: async ({ tokens }) => {
171
176
  const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
172
177
  headers: { Authorization: `Bearer ${tokens.accessToken()}` },
@@ -201,6 +206,7 @@ const result = await auth.authenticate({
201
206
  authorizationCode: "code-from-callback",
202
207
  redirectUri: process.env.GOOGLE_REDIRECT_URI!,
203
208
  codeVerifier: "pkce-code-verifier",
209
+ idempotencyKey: "oauth-state",
204
210
  });
205
211
  ```
206
212
 
@@ -208,12 +214,15 @@ const result = await auth.authenticate({
208
214
 
209
215
  - Method: `"passkey"`
210
216
  - Register + authenticate supported.
217
+ - The package consumes already-verified passkey results; it does not perform raw WebAuthn attestation/assertion verification.
218
+ - Verify WebAuthn with `@simplewebauthn/server` or equivalent first, then pass the verified result into `auth.register(...)` / `auth.authenticate(...)`.
211
219
  - Config: `requiredProfileFields`, `passkeys` adapter.
212
220
 
213
221
  ### Two-Factor (Domain Plugin)
214
222
 
215
223
  - Method: `"two_factor"`
216
224
  - Adds post-primary verification (`TWO_FACTOR_REQUIRED`).
225
+ - This release supports `totp` and `recovery_code`.
217
226
  - Uses `@oslojs/otp` internally for TOTP verification and enrollment URI generation.
218
227
  - Plugin API:
219
228
  - `beginTotpEnrollment(userId)`
@@ -226,6 +235,14 @@ const result = await auth.authenticate({
226
235
  - Multi-tenant orgs, memberships, role inheritance, feature/limit entitlements, invites.
227
236
  - Validates role topology on startup (default role, owner role presence, inheritance cycles).
228
237
 
238
+ ### Stripe (Domain Plugin)
239
+
240
+ - Method: `"stripe"`
241
+ - User and organization subscriptions with typed billing subjects.
242
+ - Checkout session creation, billing portal sessions, webhook verification, local subscription snapshots.
243
+ - Plan-level features and limits, trial tracking, and organization entitlement merge support.
244
+ - Requires the `stripe` package in your application.
245
+
229
246
  ## Account Discovery
230
247
 
231
248
  Use `discover(...)` to support login/register routing logic before full auth:
@@ -243,6 +260,8 @@ See ready-to-copy integrations:
243
260
  - [`examples/sveltekit-email-otp`](./examples/sveltekit-email-otp)
244
261
  - [`examples/oauth2-google-arctic`](./examples/oauth2-google-arctic)
245
262
  - [`examples/two-factor-totp-oslo`](./examples/two-factor-totp-oslo)
263
+ - [`examples/stripe-user-billing`](./examples/stripe-user-billing)
264
+ - [`examples/stripe-organization-billing`](./examples/stripe-organization-billing)
246
265
 
247
266
  ## Scripts
248
267
 
@@ -1,6 +1,6 @@
1
- import { type AuthResult, type OperationResult } from "../types/results.js";
2
- import type { AuthConfig, AuthenticateInputFromPlugins, AuthPublicApi, AnyPlugin, PluginApiMap, PluginMethodsWithApi, RegisterInputFromPlugins } from "../types/plugins.js";
3
1
  import type { AuthRequestContext, CompleteProfileInput, DiscoverAccountDecision, DiscoverAccountInput, TwoFactorVerifyInput, UserBase } from "../types/model.js";
2
+ import type { AnyPlugin, AuthConfig, AuthPublicApi, AuthenticateInputFromPlugins, PluginApiMap, PluginMethodsWithApi, RegisterInputFromPlugins } from "../types/plugins.js";
3
+ import { type AuthResult, type OperationResult } from "../types/results.js";
4
4
  export declare class OglofusAuth<U extends UserBase, P extends readonly AnyPlugin<U>[]> implements AuthPublicApi<U, P> {
5
5
  private readonly config;
6
6
  private readonly pluginMap;
@@ -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,7 +1,7 @@
1
1
  import { AuthError } from "../errors/index.js";
2
- import { errorOperation, errorResult, successOperation, successResult, } from "../types/results.js";
3
- import { DEFAULT_SESSION_TTL_SECONDS, addSeconds, createId, normalizeEmailDefault, now, } from "./utils.js";
4
2
  import { createIssue } from "../issues/index.js";
3
+ import { errorOperation, errorResult, successOperation, successResult, } from "../types/results.js";
4
+ import { DEFAULT_SESSION_TTL_SECONDS, addSeconds, createId, deterministicTokenHash, normalizeEmailDefault, now, } from "./utils.js";
5
5
  const hasEmail = (value) => typeof value === "object" && value !== null && "email" in value && typeof value.email === "string";
6
6
  const toAuthError = (error) => {
7
7
  if (error instanceof AuthError) {
@@ -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();
@@ -18,18 +26,30 @@ export class OglofusAuth {
18
26
  this.config = config;
19
27
  this.normalizeEmail = config.normalize?.email ?? normalizeEmailDefault;
20
28
  this.validatePlugins();
29
+ const getPluginApi = (method) => {
30
+ if (!this.apiMap.has(method)) {
31
+ return null;
32
+ }
33
+ return this.apiMap.get(method);
34
+ };
21
35
  for (const plugin of config.plugins) {
22
36
  this.pluginMap.set(plugin.method, plugin);
23
37
  if (plugin.createApi) {
24
38
  this.apiMap.set(plugin.method, plugin.createApi({
25
39
  adapters: this.config.adapters,
26
40
  now,
41
+ security: this.config.security,
42
+ getPluginApi,
27
43
  }));
28
44
  }
29
45
  }
30
46
  }
31
47
  async discover(input, request) {
32
48
  const email = this.normalizeEmail(input.email);
49
+ const rateLimitError = await this.consumeRateLimit("discover", email, request);
50
+ if (rateLimitError) {
51
+ return errorOperation(rateLimitError);
52
+ }
33
53
  const mode = this.config.accountDiscovery?.mode ?? "private";
34
54
  if (mode === "private") {
35
55
  return successOperation({
@@ -83,6 +103,17 @@ export class OglofusAuth {
83
103
  return errorResult(new AuthError("METHOD_DISABLED", `Method ${method} is disabled.`, 400));
84
104
  }
85
105
  const normalized = this.normalizeAuthInput(input);
106
+ const rateLimitError = await this.consumeRateLimit("authenticate", this.getRateLimitIdentity(normalized), request);
107
+ if (rateLimitError) {
108
+ await this.writeAudit({
109
+ action: "authenticate",
110
+ method,
111
+ requestId: request?.requestId,
112
+ success: false,
113
+ errorCode: rateLimitError.code,
114
+ });
115
+ return errorResult(rateLimitError);
116
+ }
86
117
  const validated = this.runValidator(plugin.validators?.authenticate, normalized);
87
118
  if (!validated.ok) {
88
119
  return validated.result;
@@ -145,6 +176,17 @@ export class OglofusAuth {
145
176
  return errorResult(new AuthError("METHOD_NOT_REGISTERABLE", `Method ${method} does not support register.`, 400));
146
177
  }
147
178
  const normalized = this.normalizeAuthInput(input);
179
+ const rateLimitError = await this.consumeRateLimit("register", this.getRateLimitIdentity(normalized), request);
180
+ if (rateLimitError) {
181
+ await this.writeAudit({
182
+ action: "register",
183
+ method,
184
+ requestId: request?.requestId,
185
+ success: false,
186
+ errorCode: rateLimitError.code,
187
+ });
188
+ return errorResult(rateLimitError);
189
+ }
148
190
  const validated = this.runValidator(plugin.validators?.register, normalized);
149
191
  if (!validated.ok) {
150
192
  return validated.result;
@@ -195,6 +237,16 @@ export class OglofusAuth {
195
237
  }
196
238
  const record = await pending.findById(input.pendingProfileId);
197
239
  if (!record || record.consumedAt || record.expiresAt.getTime() <= now().getTime()) {
240
+ await this.writeAudit({
241
+ action: "complete_profile",
242
+ method: "complete_profile",
243
+ requestId: request?.requestId,
244
+ success: false,
245
+ errorCode: "PROFILE_COMPLETION_EXPIRED",
246
+ meta: {
247
+ pendingProfileId: input.pendingProfileId,
248
+ },
249
+ });
198
250
  return errorResult(new AuthError("PROFILE_COMPLETION_EXPIRED", "Profile completion has expired.", 400));
199
251
  }
200
252
  const missingIssues = [];
@@ -205,12 +257,18 @@ export class OglofusAuth {
205
257
  }
206
258
  }
207
259
  if (missingIssues.length > 0) {
260
+ await this.writeAudit({
261
+ action: "complete_profile",
262
+ method: record.sourceMethod,
263
+ requestId: request?.requestId,
264
+ success: false,
265
+ errorCode: "INVALID_INPUT",
266
+ meta: {
267
+ pendingProfileId: input.pendingProfileId,
268
+ },
269
+ });
208
270
  return errorResult(new AuthError("INVALID_INPUT", "Missing required profile fields.", 400, missingIssues), missingIssues);
209
271
  }
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
272
  const email = record.email ? this.normalizeEmail(record.email) : undefined;
215
273
  const merged = {
216
274
  ...record.prefill,
@@ -220,44 +278,94 @@ export class OglofusAuth {
220
278
  if (merged.emailVerified === undefined) {
221
279
  merged.emailVerified = false;
222
280
  }
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);
281
+ const plugin = this.getAuthMethodPlugin(record.sourceMethod);
282
+ if (record.continuation && (!plugin || !plugin.completePendingProfile)) {
283
+ const error = new AuthError("PLUGIN_MISCONFIGURED", `Pending profile for '${record.sourceMethod}' cannot be completed by the current plugin set.`, 500);
284
+ await this.writeAudit({
285
+ action: "complete_profile",
286
+ method: record.sourceMethod,
287
+ requestId: request?.requestId,
288
+ success: false,
289
+ errorCode: error.code,
290
+ meta: {
291
+ pendingProfileId: input.pendingProfileId,
292
+ },
293
+ });
294
+ return errorResult(error);
295
+ }
296
+ const ctx = this.makeContext(request);
297
+ let finalizedUserId;
298
+ const finalize = async () => {
299
+ const persisted = await this.persistCompletedProfile(email, merged);
300
+ if (!persisted.ok) {
301
+ return persisted;
228
302
  }
229
- else {
230
- user = await this.config.adapters.users.create(merged);
303
+ finalizedUserId = persisted.user.id;
304
+ if (plugin?.completePendingProfile) {
305
+ const continuation = await plugin.completePendingProfile(ctx, {
306
+ record,
307
+ user: persisted.user,
308
+ });
309
+ if (!continuation.ok) {
310
+ return { ok: false, error: continuation.error };
311
+ }
231
312
  }
313
+ const consumed = await pending.consume(input.pendingProfileId);
314
+ if (!consumed) {
315
+ return {
316
+ ok: false,
317
+ error: new AuthError("PROFILE_COMPLETION_EXPIRED", "Profile completion has expired.", 400),
318
+ };
319
+ }
320
+ return { ok: true, user: persisted.user };
321
+ };
322
+ let finalized;
323
+ try {
324
+ finalized = this.config.adapters.withTransaction
325
+ ? await this.config.adapters.withTransaction(finalize)
326
+ : await finalize();
232
327
  }
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;
328
+ catch (error) {
329
+ const authError = toAuthError(error);
330
+ await this.writeAudit({
331
+ action: "complete_profile",
332
+ method: record.sourceMethod,
333
+ requestId: request?.requestId,
334
+ success: false,
335
+ errorCode: authError.code,
336
+ userId: finalizedUserId,
337
+ meta: {
338
+ pendingProfileId: input.pendingProfileId,
339
+ },
340
+ });
341
+ return errorResult(authError);
251
342
  }
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));
343
+ if (!finalized.ok) {
344
+ await this.writeAudit({
345
+ action: "complete_profile",
346
+ method: record.sourceMethod,
347
+ requestId: request?.requestId,
348
+ success: false,
349
+ errorCode: finalized.error.code,
350
+ userId: finalizedUserId,
351
+ meta: {
352
+ pendingProfileId: input.pendingProfileId,
353
+ },
354
+ });
355
+ return errorResult(finalized.error);
255
356
  }
256
- await this.config.adapters.sessions.setActiveOrganization(sessionId, organizationId);
257
- return successOperation({
258
- sessionId,
259
- activeOrganizationId: organizationId,
357
+ const sessionId = await this.createSession(finalized.user.id);
358
+ await this.writeAudit({
359
+ action: "complete_profile",
360
+ method: record.sourceMethod,
361
+ requestId: request?.requestId,
362
+ success: true,
363
+ userId: finalized.user.id,
364
+ meta: {
365
+ pendingProfileId: input.pendingProfileId,
366
+ },
260
367
  });
368
+ return successResult(finalized.user, sessionId);
261
369
  }
262
370
  async validateSession(sessionId, _request) {
263
371
  const session = await this.config.adapters.sessions.findById(sessionId);
@@ -289,11 +397,32 @@ export class OglofusAuth {
289
397
  email: this.normalizeEmail(input.email),
290
398
  };
291
399
  }
400
+ async persistCompletedProfile(email, merged) {
401
+ if (email) {
402
+ const existing = await this.config.adapters.users.findByEmail(email);
403
+ if (existing) {
404
+ const updated = await this.config.adapters.users.update(existing.id, merged);
405
+ if (!updated) {
406
+ return { ok: false, error: new AuthError("USER_NOT_FOUND", "User not found.", 404) };
407
+ }
408
+ return { ok: true, user: updated };
409
+ }
410
+ }
411
+ const created = await this.config.adapters.users.create(merged);
412
+ return { ok: true, user: created };
413
+ }
292
414
  makeContext(request) {
293
415
  return {
294
416
  adapters: this.config.adapters,
295
417
  now,
418
+ security: this.config.security,
296
419
  request,
420
+ getPluginApi: (method) => {
421
+ if (!this.apiMap.has(method)) {
422
+ return null;
423
+ }
424
+ return this.apiMap.get(method);
425
+ },
297
426
  };
298
427
  }
299
428
  async createSession(userId) {
@@ -322,6 +451,66 @@ export class OglofusAuth {
322
451
  }
323
452
  return api;
324
453
  }
454
+ resolveRateLimitPolicy(scope) {
455
+ if (!this.config.adapters.rateLimiter) {
456
+ return null;
457
+ }
458
+ return this.config.security?.rateLimits?.[scope] ?? DEFAULT_RATE_LIMIT_POLICIES[scope];
459
+ }
460
+ async consumeRateLimit(scope, identity, request) {
461
+ const policy = this.resolveRateLimitPolicy(scope);
462
+ if (!policy || !this.config.adapters.rateLimiter) {
463
+ return null;
464
+ }
465
+ const result = await this.config.adapters.rateLimiter.consume(this.makeRateLimitKey(scope, identity, request), policy.limit, policy.windowSeconds);
466
+ if (result.allowed) {
467
+ return null;
468
+ }
469
+ return new AuthError("RATE_LIMITED", "Too many requests.", 429, [], result.retryAfterSeconds === undefined ? undefined : { retryAfterSeconds: result.retryAfterSeconds });
470
+ }
471
+ makeRateLimitKey(scope, identity, request) {
472
+ if (!request?.ip) {
473
+ return `${scope}:identity:${identity}`;
474
+ }
475
+ return `${scope}:ip:${request.ip}:identity:${identity}`;
476
+ }
477
+ getRateLimitIdentity(input) {
478
+ if (hasEmail(input)) {
479
+ return this.normalizeEmail(input.email);
480
+ }
481
+ if (!input || typeof input !== "object") {
482
+ return "anonymous";
483
+ }
484
+ const record = input;
485
+ if (typeof record.challengeId === "string" && record.challengeId.length > 0) {
486
+ return `challenge:${record.challengeId}`;
487
+ }
488
+ if (typeof record.pendingAuthId === "string" && record.pendingAuthId.length > 0) {
489
+ return `pending:${record.pendingAuthId}`;
490
+ }
491
+ if (typeof record.token === "string" && record.token.length > 0) {
492
+ return `token:${deterministicTokenHash(record.token, "rate-limit:magic-link")}`;
493
+ }
494
+ if (typeof record.provider === "string" && record.provider.length > 0) {
495
+ return `provider:${record.provider}`;
496
+ }
497
+ const authentication = record.authentication;
498
+ if (authentication &&
499
+ typeof authentication === "object" &&
500
+ typeof authentication.credentialId === "string") {
501
+ return `credential:${String(authentication.credentialId)}`;
502
+ }
503
+ const registration = record.registration;
504
+ if (registration &&
505
+ typeof registration === "object" &&
506
+ typeof registration.credentialId === "string") {
507
+ return `credential:${String(registration.credentialId)}`;
508
+ }
509
+ if (typeof record.method === "string" && record.method.length > 0) {
510
+ return `method:${record.method}`;
511
+ }
512
+ return "anonymous";
513
+ }
325
514
  runValidator(validator, input) {
326
515
  if (!validator) {
327
516
  return { ok: true, input: input };
@@ -343,6 +532,7 @@ export class OglofusAuth {
343
532
  const methods = new Set();
344
533
  let twoFactorCount = 0;
345
534
  let organizationsCount = 0;
535
+ let stripeCount = 0;
346
536
  for (const plugin of this.config.plugins) {
347
537
  if (methods.has(plugin.method)) {
348
538
  throw new AuthError("PLUGIN_METHOD_CONFLICT", `Plugin method conflict for '${plugin.method}'.`, 500);
@@ -354,6 +544,9 @@ export class OglofusAuth {
354
544
  if (plugin.method === "organizations") {
355
545
  organizationsCount += 1;
356
546
  }
547
+ if (plugin.method === "stripe") {
548
+ stripeCount += 1;
549
+ }
357
550
  if (plugin.kind === "auth_method") {
358
551
  if (plugin.supports.register && !plugin.register) {
359
552
  throw new AuthError("PLUGIN_MISCONFIGURED", `Plugin '${plugin.method}' declares register support but no register handler.`, 500);
@@ -375,11 +568,17 @@ export class OglofusAuth {
375
568
  if (organizationsCount > 1) {
376
569
  throw new AuthError("PLUGIN_MISCONFIGURED", "At most one organizations plugin is allowed.", 500);
377
570
  }
571
+ if (stripeCount > 1) {
572
+ throw new AuthError("PLUGIN_MISCONFIGURED", "At most one stripe plugin is allowed.", 500);
573
+ }
378
574
  if ((this.config.accountDiscovery?.mode ?? "private") === "explicit" && !this.config.adapters.identity) {
379
575
  throw new AuthError("PLUGIN_MISCONFIGURED", "identity adapter is required when accountDiscovery.mode is explicit.", 500);
380
576
  }
577
+ const orgPlugin = this.config.plugins.find((plugin) => plugin.method === "organizations");
578
+ if (orgPlugin && !orgPlugin.__organizationConfig?.handlers?.organizationSessions) {
579
+ throw new AuthError("PLUGIN_MISCONFIGURED", "organizations plugin requires handlers.organizationSessions.", 500);
580
+ }
381
581
  if (this.config.validateConfigOnStart) {
382
- const orgPlugin = this.config.plugins.find((plugin) => plugin.method === "organizations");
383
582
  const roleConfig = orgPlugin?.__organizationConfig?.handlers?.roles;
384
583
  const defaultRole = orgPlugin?.__organizationConfig?.handlers?.defaultRole;
385
584
  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) {
@@ -1,5 +1,5 @@
1
- import { createIssue } from "../issues/index.js";
2
1
  import { AuthError } from "../errors/index.js";
2
+ import { createIssue } from "../issues/index.js";
3
3
  export const ensureFields = (source, fields, basePath = []) => {
4
4
  const issues = fields
5
5
  .filter((field) => {
@@ -1,7 +1,6 @@
1
- import type { UserBase } from "../types/model.js";
2
- import type { ProfileCompletionState, TwoFactorRequiredMeta } from "../types/model.js";
3
1
  import type { Issue } from "../issues/index.js";
4
- export type AuthErrorCode = "INVALID_INPUT" | "METHOD_DISABLED" | "METHOD_NOT_REGISTERABLE" | "ACCOUNT_NOT_FOUND" | "ACCOUNT_EXISTS" | "ACCOUNT_EXISTS_WITH_DIFFERENT_METHOD" | "ORGANIZATION_NOT_FOUND" | "MEMBERSHIP_NOT_FOUND" | "MEMBERSHIP_FORBIDDEN" | "ROLE_INVALID" | "ROLE_NOT_ASSIGNABLE" | "ORGANIZATION_INVITE_INVALID" | "ORGANIZATION_INVITE_EXPIRED" | "SEAT_LIMIT_REACHED" | "FEATURE_DISABLED" | "LIMIT_EXCEEDED" | "LAST_OWNER_GUARD" | "SESSION_NOT_FOUND" | "INVALID_CREDENTIALS" | "USER_NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "DELIVERY_FAILED" | "EMAIL_NOT_VERIFIED" | "OTP_EXPIRED" | "OTP_INVALID" | "MAGIC_LINK_EXPIRED" | "MAGIC_LINK_INVALID" | "OAUTH2_PROVIDER_DISABLED" | "OAUTH2_EXCHANGE_FAILED" | "PASSKEY_INVALID_ASSERTION" | "PASSKEY_INVALID_ATTESTATION" | "PASSKEY_CHALLENGE_EXPIRED" | "PROFILE_COMPLETION_REQUIRED" | "PROFILE_COMPLETION_EXPIRED" | "TWO_FACTOR_REQUIRED" | "TWO_FACTOR_INVALID" | "TWO_FACTOR_EXPIRED" | "RECOVERY_CODE_INVALID" | "PLUGIN_METHOD_CONFLICT" | "PLUGIN_MISCONFIGURED" | "INTERNAL_ERROR";
2
+ import type { ProfileCompletionState, TwoFactorRequiredMeta, UserBase } from "../types/model.js";
3
+ export type AuthErrorCode = "INVALID_INPUT" | "METHOD_DISABLED" | "METHOD_NOT_REGISTERABLE" | "ACCOUNT_NOT_FOUND" | "ACCOUNT_EXISTS" | "ACCOUNT_EXISTS_WITH_DIFFERENT_METHOD" | "ORGANIZATION_NOT_FOUND" | "MEMBERSHIP_NOT_FOUND" | "MEMBERSHIP_FORBIDDEN" | "ROLE_INVALID" | "ROLE_NOT_ASSIGNABLE" | "ORGANIZATION_INVITE_INVALID" | "ORGANIZATION_INVITE_EXPIRED" | "SEAT_LIMIT_REACHED" | "FEATURE_DISABLED" | "LIMIT_EXCEEDED" | "LAST_OWNER_GUARD" | "SESSION_NOT_FOUND" | "INVALID_CREDENTIALS" | "USER_NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "DELIVERY_FAILED" | "EMAIL_NOT_VERIFIED" | "OTP_EXPIRED" | "OTP_INVALID" | "MAGIC_LINK_EXPIRED" | "MAGIC_LINK_INVALID" | "OAUTH2_PROVIDER_DISABLED" | "OAUTH2_EXCHANGE_FAILED" | "PASSKEY_INVALID_ASSERTION" | "PASSKEY_INVALID_ATTESTATION" | "PASSKEY_CHALLENGE_EXPIRED" | "CUSTOMER_NOT_FOUND" | "SUBSCRIPTION_NOT_FOUND" | "SUBSCRIPTION_ALREADY_EXISTS" | "TRIAL_NOT_AVAILABLE" | "STRIPE_WEBHOOK_INVALID" | "PROFILE_COMPLETION_REQUIRED" | "PROFILE_COMPLETION_EXPIRED" | "TWO_FACTOR_REQUIRED" | "TWO_FACTOR_INVALID" | "TWO_FACTOR_EXPIRED" | "RECOVERY_CODE_INVALID" | "PLUGIN_METHOD_CONFLICT" | "PLUGIN_MISCONFIGURED" | "INTERNAL_ERROR";
5
4
  export declare class AuthError extends Error {
6
5
  readonly code: AuthErrorCode;
7
6
  readonly status: number;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export * from "./types/index.js";
2
- export * from "./issues/index.js";
3
- export * from "./errors/index.js";
4
- export * from "./core/events.js";
5
1
  export * from "./core/auth.js";
2
+ export * from "./core/events.js";
3
+ export * from "./errors/index.js";
4
+ export * from "./issues/index.js";
6
5
  export * from "./plugins/index.js";
6
+ export * from "./types/index.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- export * from "./types/index.js";
2
- export * from "./issues/index.js";
3
- export * from "./errors/index.js";
4
- export * from "./core/events.js";
5
1
  export * from "./core/auth.js";
2
+ export * from "./core/events.js";
3
+ export * from "./errors/index.js";
4
+ export * from "./issues/index.js";
6
5
  export * from "./plugins/index.js";
6
+ export * from "./types/index.js";