@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 +33 -14
- package/dist/core/auth.d.ts +7 -6
- package/dist/core/auth.js +238 -39
- package/dist/core/utils.d.ts +1 -0
- package/dist/core/utils.js +2 -1
- package/dist/core/validators.js +1 -1
- package/dist/errors/index.d.ts +2 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/plugins/email-otp.js +31 -18
- package/dist/plugins/index.d.ts +3 -2
- package/dist/plugins/index.js +3 -2
- package/dist/plugins/magic-link.js +15 -24
- package/dist/plugins/oauth2.d.ts +8 -1
- package/dist/plugins/oauth2.js +72 -39
- package/dist/plugins/organizations.d.ts +1 -1
- package/dist/plugins/organizations.js +58 -9
- package/dist/plugins/passkey.js +22 -31
- package/dist/plugins/password.js +4 -7
- package/dist/plugins/stripe.d.ts +19 -0
- package/dist/plugins/stripe.js +583 -0
- package/dist/plugins/two-factor.js +3 -6
- package/dist/types/adapters.d.ts +58 -23
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +2 -2
- package/dist/types/model.d.ts +86 -13
- package/dist/types/plugins.d.ts +91 -5
- package/dist/types/results.d.ts +1 -1
- package/dist/types/results.js +11 -2
- package/package.json +10 -3
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
|
|
|
@@ -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 `
|
|
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
|
-
|
|
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
|
|
package/dist/core/auth.d.ts
CHANGED
|
@@ -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
|
-
|
|
224
|
-
if (
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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) {
|
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) {
|
package/dist/core/validators.js
CHANGED
package/dist/errors/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|