@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
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createId } from "../core/utils.js";
|
|
3
|
+
import { AuthError } from "../errors/index.js";
|
|
4
|
+
import { errorOperation, successOperation } from "../types/results.js";
|
|
5
|
+
const SUBJECT_KIND_KEY = "oglofus_subject_kind";
|
|
6
|
+
const SUBJECT_ID_KEY = "oglofus_subject_id";
|
|
7
|
+
const PLAN_KEY_KEY = "oglofus_plan_key";
|
|
8
|
+
const BILLING_CYCLE_KEY = "oglofus_billing_cycle";
|
|
9
|
+
const ENTITLED_STATUSES = new Set(["trialing", "active"]);
|
|
10
|
+
const subjectId = (subject) => subject.kind === "user" ? subject.userId : subject.organizationId;
|
|
11
|
+
const subjectsEqual = (left, right) => left.kind === right.kind && subjectId(left) === subjectId(right);
|
|
12
|
+
const referenceForSubject = (subject) => `${subject.kind}:${subjectId(subject)}`;
|
|
13
|
+
const metadataForSubject = (subject, planKey, billingCycle, metadata) => ({
|
|
14
|
+
...(metadata ?? {}),
|
|
15
|
+
[SUBJECT_KIND_KEY]: subject.kind,
|
|
16
|
+
[SUBJECT_ID_KEY]: subjectId(subject),
|
|
17
|
+
[PLAN_KEY_KEY]: planKey,
|
|
18
|
+
[BILLING_CYCLE_KEY]: billingCycle,
|
|
19
|
+
});
|
|
20
|
+
const toDate = (value) => value === null || value === undefined ? value : new Date(value * 1_000);
|
|
21
|
+
const toMetadataRecord = (metadata) => {
|
|
22
|
+
if (!metadata) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
27
|
+
if (typeof value === "string") {
|
|
28
|
+
out[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
const isAllowedByCustomerMode = (customerMode, subject) => {
|
|
34
|
+
if (customerMode === "both") {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return customerMode === subject.kind;
|
|
38
|
+
};
|
|
39
|
+
const validatePlan = (plan, customerMode) => {
|
|
40
|
+
if (!plan.prices.monthly && !plan.prices.annual) {
|
|
41
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Stripe plan '${plan.key}' must define at least one price.`, 500);
|
|
42
|
+
}
|
|
43
|
+
if (!isAllowedByCustomerMode(customerMode, plan.scope === "user" ? { kind: "user", userId: "preview" } : { kind: "organization", organizationId: "preview" })) {
|
|
44
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Stripe plan '${plan.key}' scope is incompatible with customerMode '${customerMode}'.`, 500);
|
|
45
|
+
}
|
|
46
|
+
if (plan.scope === "user" && plan.seats?.enabled) {
|
|
47
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Stripe plan '${plan.key}' cannot enable seats for user billing.`, 500);
|
|
48
|
+
}
|
|
49
|
+
if (plan.trial && plan.trial.days <= 0) {
|
|
50
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Stripe plan '${plan.key}' trial.days must be greater than zero.`, 500);
|
|
51
|
+
}
|
|
52
|
+
if (plan.seats?.enabled &&
|
|
53
|
+
plan.seats.minimum !== undefined &&
|
|
54
|
+
plan.seats.maximum !== undefined &&
|
|
55
|
+
plan.seats.minimum > plan.seats.maximum) {
|
|
56
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Stripe plan '${plan.key}' seats.minimum cannot exceed seats.maximum.`, 500);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const withSeatLimit = (limits, seats, limitKey) => {
|
|
60
|
+
if (limitKey === undefined || seats === null || seats === undefined) {
|
|
61
|
+
return limits;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...limits,
|
|
65
|
+
[limitKey]: seats,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const validatePlans = (plans, customerMode, trials) => {
|
|
69
|
+
const keys = new Set();
|
|
70
|
+
let trialPlans = 0;
|
|
71
|
+
for (const plan of plans) {
|
|
72
|
+
validatePlan(plan, customerMode);
|
|
73
|
+
if (keys.has(plan.key)) {
|
|
74
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Duplicate Stripe plan key '${plan.key}'.`, 500);
|
|
75
|
+
}
|
|
76
|
+
keys.add(plan.key);
|
|
77
|
+
if (plan.trial) {
|
|
78
|
+
trialPlans += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (trialPlans > 0 && !trials) {
|
|
82
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", "Stripe plans with trial configuration require handlers.trials.", 500);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const normalizeStatus = (status) => status;
|
|
86
|
+
export const stripePlugin = (config) => {
|
|
87
|
+
const customerMode = config.customerMode ?? "both";
|
|
88
|
+
if (Array.isArray(config.plans)) {
|
|
89
|
+
validatePlans(config.plans, customerMode, config.handlers.trials);
|
|
90
|
+
}
|
|
91
|
+
let plansPromise = null;
|
|
92
|
+
const loadPlans = async () => {
|
|
93
|
+
if (!plansPromise) {
|
|
94
|
+
plansPromise = (async () => {
|
|
95
|
+
const resolved = typeof config.plans === "function" ? await config.plans() : config.plans;
|
|
96
|
+
validatePlans(resolved, customerMode, config.handlers.trials);
|
|
97
|
+
return resolved;
|
|
98
|
+
})();
|
|
99
|
+
}
|
|
100
|
+
return plansPromise;
|
|
101
|
+
};
|
|
102
|
+
const resolvePlanByKey = async (planKey) => {
|
|
103
|
+
const plans = await loadPlans();
|
|
104
|
+
const plan = plans.find((candidate) => candidate.key === planKey);
|
|
105
|
+
if (!plan) {
|
|
106
|
+
throw new AuthError("INVALID_INPUT", `Unknown Stripe plan '${planKey}'.`, 400);
|
|
107
|
+
}
|
|
108
|
+
return plan;
|
|
109
|
+
};
|
|
110
|
+
const resolvePlanByPriceId = async (priceId, fallbackPlanKey) => {
|
|
111
|
+
const plans = await loadPlans();
|
|
112
|
+
for (const plan of plans) {
|
|
113
|
+
if (plan.prices.monthly?.priceId === priceId) {
|
|
114
|
+
return { plan, billingCycle: "monthly" };
|
|
115
|
+
}
|
|
116
|
+
if (plan.prices.annual?.priceId === priceId) {
|
|
117
|
+
return { plan, billingCycle: "annual" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (fallbackPlanKey) {
|
|
121
|
+
const fallback = plans.find((candidate) => candidate.key === fallbackPlanKey);
|
|
122
|
+
if (fallback) {
|
|
123
|
+
const billingCycle = fallback.prices.annual?.priceId === priceId ? "annual" : "monthly";
|
|
124
|
+
return { plan: fallback, billingCycle };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new AuthError("PLUGIN_MISCONFIGURED", `Unable to resolve Stripe plan for price '${priceId}'.`, 500);
|
|
128
|
+
};
|
|
129
|
+
const assertSubjectAllowed = (subject) => {
|
|
130
|
+
if (!isAllowedByCustomerMode(customerMode, subject)) {
|
|
131
|
+
throw new AuthError("INVALID_INPUT", `Billing subject kind '${subject.kind}' is not enabled.`, 400);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const assertPlanForSubject = (subject, plan) => {
|
|
135
|
+
if (subject.kind !== plan.scope) {
|
|
136
|
+
throw new AuthError("INVALID_INPUT", `Plan '${plan.key}' cannot be used for ${subject.kind} billing.`, 400);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const resolveQuantity = (subject, plan, requestedSeats) => {
|
|
140
|
+
if (!plan.seats?.enabled) {
|
|
141
|
+
if (requestedSeats !== undefined) {
|
|
142
|
+
throw new AuthError("INVALID_INPUT", "Seats are not supported for this plan.", 400);
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (subject.kind !== "organization") {
|
|
147
|
+
throw new AuthError("INVALID_INPUT", "Seats are only supported for organizations.", 400);
|
|
148
|
+
}
|
|
149
|
+
const quantity = requestedSeats ?? plan.seats.minimum ?? 1;
|
|
150
|
+
if (!Number.isInteger(quantity) || quantity <= 0) {
|
|
151
|
+
throw new AuthError("INVALID_INPUT", "Seats must be a positive integer.", 400);
|
|
152
|
+
}
|
|
153
|
+
if (plan.seats.minimum !== undefined && quantity < plan.seats.minimum) {
|
|
154
|
+
throw new AuthError("INVALID_INPUT", `Seats must be at least ${plan.seats.minimum}.`, 400);
|
|
155
|
+
}
|
|
156
|
+
if (plan.seats.maximum !== undefined && quantity > plan.seats.maximum) {
|
|
157
|
+
throw new AuthError("INVALID_INPUT", `Seats must be at most ${plan.seats.maximum}.`, 400);
|
|
158
|
+
}
|
|
159
|
+
return quantity;
|
|
160
|
+
};
|
|
161
|
+
const createCustomerRecord = async (subject, stripeCustomerId, now) => {
|
|
162
|
+
const record = {
|
|
163
|
+
id: createId(),
|
|
164
|
+
subject,
|
|
165
|
+
stripeCustomerId,
|
|
166
|
+
createdAt: now,
|
|
167
|
+
updatedAt: now,
|
|
168
|
+
};
|
|
169
|
+
await config.handlers.customers.create(record);
|
|
170
|
+
return record;
|
|
171
|
+
};
|
|
172
|
+
const ensureCustomer = async (ctx, subject, now) => {
|
|
173
|
+
const existing = await config.handlers.customers.findBySubject(subject);
|
|
174
|
+
if (existing) {
|
|
175
|
+
return existing;
|
|
176
|
+
}
|
|
177
|
+
let email;
|
|
178
|
+
if (subject.kind === "user") {
|
|
179
|
+
const user = await ctx.adapters.users.findById(subject.userId);
|
|
180
|
+
if (!user) {
|
|
181
|
+
throw new AuthError("USER_NOT_FOUND", "User not found.", 404);
|
|
182
|
+
}
|
|
183
|
+
email = user.email;
|
|
184
|
+
}
|
|
185
|
+
const customer = await config.stripe.customers.create({
|
|
186
|
+
email,
|
|
187
|
+
metadata: {
|
|
188
|
+
[SUBJECT_KIND_KEY]: subject.kind,
|
|
189
|
+
[SUBJECT_ID_KEY]: subjectId(subject),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return createCustomerRecord(subject, customer.id, now);
|
|
193
|
+
};
|
|
194
|
+
const computeEntitlements = (plan, status, seats) => {
|
|
195
|
+
if (!ENTITLED_STATUSES.has(status)) {
|
|
196
|
+
return { features: {}, limits: {} };
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
features: { ...(plan.features ?? {}) },
|
|
200
|
+
limits: withSeatLimit({ ...(plan.limits ?? {}) }, seats, plan.seats?.enabled ? plan.seats.limitKey : undefined),
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
const resolveSubjectFromMetadata = async (metadata, stripeCustomerId) => {
|
|
204
|
+
const kind = metadata[SUBJECT_KIND_KEY];
|
|
205
|
+
const id = metadata[SUBJECT_ID_KEY];
|
|
206
|
+
if (kind === "user" && id) {
|
|
207
|
+
return { kind, userId: id };
|
|
208
|
+
}
|
|
209
|
+
if (kind === "organization" && id) {
|
|
210
|
+
return { kind, organizationId: id };
|
|
211
|
+
}
|
|
212
|
+
const existing = await config.handlers.customers.findByStripeCustomerId(stripeCustomerId);
|
|
213
|
+
if (!existing) {
|
|
214
|
+
throw new AuthError("CUSTOMER_NOT_FOUND", "Stripe customer is not mapped to a billing subject.", 404);
|
|
215
|
+
}
|
|
216
|
+
return existing.subject;
|
|
217
|
+
};
|
|
218
|
+
const upsertSubscriptionSnapshot = async (subscription) => {
|
|
219
|
+
const subscriptionRecord = subscription;
|
|
220
|
+
const firstItem = subscription.items.data[0];
|
|
221
|
+
if (!firstItem?.price?.id) {
|
|
222
|
+
throw new AuthError("INTERNAL_ERROR", "Stripe subscription is missing a primary price item.", 500);
|
|
223
|
+
}
|
|
224
|
+
const metadata = toMetadataRecord(subscription.metadata);
|
|
225
|
+
const { plan, billingCycle } = await resolvePlanByPriceId(firstItem.price.id, metadata[PLAN_KEY_KEY]);
|
|
226
|
+
const subject = await resolveSubjectFromMetadata(metadata, String(subscription.customer));
|
|
227
|
+
const existing = await config.handlers.subscriptions.findByStripeSubscriptionId(subscription.id);
|
|
228
|
+
const normalizedStatus = normalizeStatus(subscription.status);
|
|
229
|
+
const seats = typeof firstItem.quantity === "number" ? firstItem.quantity : null;
|
|
230
|
+
const entitlements = computeEntitlements(plan, normalizedStatus, seats);
|
|
231
|
+
const snapshot = {
|
|
232
|
+
id: existing?.id ?? createId(),
|
|
233
|
+
subject,
|
|
234
|
+
stripeCustomerId: String(subscription.customer),
|
|
235
|
+
stripeSubscriptionId: subscription.id,
|
|
236
|
+
stripePriceId: firstItem.price.id,
|
|
237
|
+
planKey: plan.key,
|
|
238
|
+
status: normalizedStatus,
|
|
239
|
+
billingCycle,
|
|
240
|
+
seats,
|
|
241
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
242
|
+
currentPeriodStart: toDate(subscriptionRecord.current_period_start),
|
|
243
|
+
currentPeriodEnd: toDate(subscriptionRecord.current_period_end),
|
|
244
|
+
trialStartedAt: toDate(subscription.trial_start),
|
|
245
|
+
trialEndsAt: toDate(subscription.trial_end),
|
|
246
|
+
canceledAt: toDate(subscription.canceled_at),
|
|
247
|
+
features: entitlements.features,
|
|
248
|
+
limits: entitlements.limits,
|
|
249
|
+
metadata,
|
|
250
|
+
updatedAt: new Date(),
|
|
251
|
+
};
|
|
252
|
+
await config.handlers.subscriptions.upsert(snapshot);
|
|
253
|
+
if (plan.trial && config.handlers.trials && (normalizedStatus === "trialing" || normalizedStatus === "active")) {
|
|
254
|
+
await config.handlers.trials.markUsedTrial({
|
|
255
|
+
subject,
|
|
256
|
+
planKey: plan.key,
|
|
257
|
+
usedAt: snapshot.updatedAt,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return snapshot;
|
|
261
|
+
};
|
|
262
|
+
const retrieveSubscription = async (stripeSubscriptionId) => {
|
|
263
|
+
return config.stripe.subscriptions.retrieve(stripeSubscriptionId);
|
|
264
|
+
};
|
|
265
|
+
const getManagedSubscription = async (subject, subscriptionId) => {
|
|
266
|
+
const snapshot = subscriptionId
|
|
267
|
+
? await config.handlers.subscriptions.findByStripeSubscriptionId(subscriptionId)
|
|
268
|
+
: await config.handlers.subscriptions.findActiveBySubject(subject);
|
|
269
|
+
if (!snapshot || !subjectsEqual(snapshot.subject, subject)) {
|
|
270
|
+
throw new AuthError("SUBSCRIPTION_NOT_FOUND", "Subscription not found.", 404);
|
|
271
|
+
}
|
|
272
|
+
return snapshot;
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
kind: "domain",
|
|
276
|
+
method: "stripe",
|
|
277
|
+
version: "2.0.0",
|
|
278
|
+
createApi: (ctx) => ({
|
|
279
|
+
createCheckoutSession: async (input) => {
|
|
280
|
+
try {
|
|
281
|
+
assertSubjectAllowed(input.subject);
|
|
282
|
+
const plan = await resolvePlanByKey(input.planKey);
|
|
283
|
+
assertPlanForSubject(input.subject, plan);
|
|
284
|
+
const price = plan.prices[input.billingCycle];
|
|
285
|
+
if (!price) {
|
|
286
|
+
return errorOperation(new AuthError("INVALID_INPUT", `Plan '${plan.key}' does not support ${input.billingCycle} billing.`, 400));
|
|
287
|
+
}
|
|
288
|
+
const existing = await config.handlers.subscriptions.findActiveBySubject(input.subject);
|
|
289
|
+
if (existing && existing.status !== "canceled") {
|
|
290
|
+
return errorOperation(new AuthError("SUBSCRIPTION_ALREADY_EXISTS", "Subscription already exists.", 409));
|
|
291
|
+
}
|
|
292
|
+
const quantity = resolveQuantity(input.subject, plan, input.seats);
|
|
293
|
+
if (plan.trial?.oncePerSubject !== false) {
|
|
294
|
+
const alreadyUsedTrial = plan.trial && config.handlers.trials
|
|
295
|
+
? await config.handlers.trials.hasUsedTrial({
|
|
296
|
+
subject: input.subject,
|
|
297
|
+
planKey: plan.key,
|
|
298
|
+
})
|
|
299
|
+
: false;
|
|
300
|
+
if (alreadyUsedTrial) {
|
|
301
|
+
return errorOperation(new AuthError("TRIAL_NOT_AVAILABLE", "Trial already consumed for this plan.", 409));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const customer = await ensureCustomer(ctx, input.subject, ctx.now());
|
|
305
|
+
const metadata = metadataForSubject(input.subject, plan.key, input.billingCycle, {
|
|
306
|
+
...(plan.metadata ?? {}),
|
|
307
|
+
...(input.metadata ?? {}),
|
|
308
|
+
});
|
|
309
|
+
const session = await config.stripe.checkout.sessions.create({
|
|
310
|
+
mode: "subscription",
|
|
311
|
+
customer: customer.stripeCustomerId,
|
|
312
|
+
success_url: input.successUrl,
|
|
313
|
+
cancel_url: input.cancelUrl,
|
|
314
|
+
client_reference_id: referenceForSubject(input.subject),
|
|
315
|
+
locale: input.locale,
|
|
316
|
+
line_items: [
|
|
317
|
+
{
|
|
318
|
+
price: price.priceId,
|
|
319
|
+
quantity,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
metadata,
|
|
323
|
+
subscription_data: {
|
|
324
|
+
metadata,
|
|
325
|
+
trial_period_days: plan.trial?.days,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
if (!session.url) {
|
|
329
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Stripe checkout session did not return a URL.", 500));
|
|
330
|
+
}
|
|
331
|
+
return successOperation({
|
|
332
|
+
url: session.url,
|
|
333
|
+
checkoutSessionId: session.id,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (error instanceof AuthError) {
|
|
338
|
+
return errorOperation(error);
|
|
339
|
+
}
|
|
340
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to create checkout session.", 500));
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
createBillingPortalSession: async (input) => {
|
|
344
|
+
try {
|
|
345
|
+
assertSubjectAllowed(input.subject);
|
|
346
|
+
const customer = await config.handlers.customers.findBySubject(input.subject);
|
|
347
|
+
if (!customer) {
|
|
348
|
+
return errorOperation(new AuthError("CUSTOMER_NOT_FOUND", "Billing customer not found.", 404));
|
|
349
|
+
}
|
|
350
|
+
const session = await config.stripe.billingPortal.sessions.create({
|
|
351
|
+
customer: customer.stripeCustomerId,
|
|
352
|
+
return_url: input.returnUrl,
|
|
353
|
+
locale: input.locale,
|
|
354
|
+
});
|
|
355
|
+
return successOperation({ url: session.url });
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error instanceof AuthError) {
|
|
359
|
+
return errorOperation(error);
|
|
360
|
+
}
|
|
361
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to create billing portal session.", 500));
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
getSubscription: async (input) => {
|
|
365
|
+
try {
|
|
366
|
+
assertSubjectAllowed(input.subject);
|
|
367
|
+
const subscription = await config.handlers.subscriptions.findActiveBySubject(input.subject);
|
|
368
|
+
return successOperation({ subscription: subscription ?? null });
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (error instanceof AuthError) {
|
|
372
|
+
return errorOperation(error);
|
|
373
|
+
}
|
|
374
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to load subscription.", 500));
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
listSubscriptions: async (input) => {
|
|
378
|
+
try {
|
|
379
|
+
assertSubjectAllowed(input.subject);
|
|
380
|
+
const subscriptions = await config.handlers.subscriptions.listBySubject(input.subject);
|
|
381
|
+
return successOperation({ subscriptions });
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (error instanceof AuthError) {
|
|
385
|
+
return errorOperation(error);
|
|
386
|
+
}
|
|
387
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to list subscriptions.", 500));
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
cancelSubscription: async (input) => {
|
|
391
|
+
try {
|
|
392
|
+
assertSubjectAllowed(input.subject);
|
|
393
|
+
const snapshot = await getManagedSubscription(input.subject, input.subscriptionId);
|
|
394
|
+
const subscription = input.atPeriodEnd
|
|
395
|
+
? await config.stripe.subscriptions.update(snapshot.stripeSubscriptionId, {
|
|
396
|
+
cancel_at_period_end: true,
|
|
397
|
+
})
|
|
398
|
+
: await config.stripe.subscriptions.cancel(snapshot.stripeSubscriptionId);
|
|
399
|
+
const updated = await upsertSubscriptionSnapshot(subscription);
|
|
400
|
+
return successOperation({ subscription: updated });
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
if (error instanceof AuthError) {
|
|
404
|
+
return errorOperation(error);
|
|
405
|
+
}
|
|
406
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to cancel subscription.", 500));
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
resumeSubscription: async (input) => {
|
|
410
|
+
try {
|
|
411
|
+
assertSubjectAllowed(input.subject);
|
|
412
|
+
const snapshot = await getManagedSubscription(input.subject, input.subscriptionId);
|
|
413
|
+
if (snapshot.status === "canceled") {
|
|
414
|
+
return errorOperation(new AuthError("CONFLICT", "Canceled subscriptions cannot be resumed.", 409));
|
|
415
|
+
}
|
|
416
|
+
if (!snapshot.cancelAtPeriodEnd) {
|
|
417
|
+
return errorOperation(new AuthError("CONFLICT", "Subscription is not scheduled to cancel.", 409));
|
|
418
|
+
}
|
|
419
|
+
const subscription = await config.stripe.subscriptions.update(snapshot.stripeSubscriptionId, {
|
|
420
|
+
cancel_at_period_end: false,
|
|
421
|
+
});
|
|
422
|
+
const updated = await upsertSubscriptionSnapshot(subscription);
|
|
423
|
+
return successOperation({ subscription: updated });
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (error instanceof AuthError) {
|
|
427
|
+
return errorOperation(error);
|
|
428
|
+
}
|
|
429
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to resume subscription.", 500));
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
changePlan: async (input) => {
|
|
433
|
+
try {
|
|
434
|
+
assertSubjectAllowed(input.subject);
|
|
435
|
+
const targetPlan = await resolvePlanByKey(input.planKey);
|
|
436
|
+
assertPlanForSubject(input.subject, targetPlan);
|
|
437
|
+
const price = targetPlan.prices[input.billingCycle];
|
|
438
|
+
if (!price) {
|
|
439
|
+
return errorOperation(new AuthError("INVALID_INPUT", `Plan '${targetPlan.key}' does not support ${input.billingCycle} billing.`, 400));
|
|
440
|
+
}
|
|
441
|
+
const snapshot = await getManagedSubscription(input.subject, input.subscriptionId);
|
|
442
|
+
const quantity = resolveQuantity(input.subject, targetPlan, input.seats);
|
|
443
|
+
const current = await retrieveSubscription(snapshot.stripeSubscriptionId);
|
|
444
|
+
const item = current.items.data[0];
|
|
445
|
+
if (!item?.id) {
|
|
446
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Stripe subscription is missing a primary item.", 500));
|
|
447
|
+
}
|
|
448
|
+
if (input.scheduleAtPeriodEnd) {
|
|
449
|
+
await config.stripe.subscriptionSchedules.create({
|
|
450
|
+
from_subscription: snapshot.stripeSubscriptionId,
|
|
451
|
+
end_behavior: "release",
|
|
452
|
+
phases: [
|
|
453
|
+
{
|
|
454
|
+
items: [
|
|
455
|
+
{
|
|
456
|
+
price: price.priceId,
|
|
457
|
+
quantity,
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
return successOperation({ subscription: snapshot });
|
|
464
|
+
}
|
|
465
|
+
const metadata = metadataForSubject(input.subject, targetPlan.key, input.billingCycle, {
|
|
466
|
+
...(targetPlan.metadata ?? {}),
|
|
467
|
+
...(snapshot.metadata ?? {}),
|
|
468
|
+
});
|
|
469
|
+
const updated = await config.stripe.subscriptions.update(snapshot.stripeSubscriptionId, {
|
|
470
|
+
cancel_at_period_end: false,
|
|
471
|
+
items: [
|
|
472
|
+
{
|
|
473
|
+
id: item.id,
|
|
474
|
+
price: price.priceId,
|
|
475
|
+
quantity,
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
metadata,
|
|
479
|
+
proration_behavior: "create_prorations",
|
|
480
|
+
});
|
|
481
|
+
return successOperation({
|
|
482
|
+
subscription: await upsertSubscriptionSnapshot(updated),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (error instanceof AuthError) {
|
|
487
|
+
return errorOperation(error);
|
|
488
|
+
}
|
|
489
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to change plan.", 500));
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
getEntitlements: async (input) => {
|
|
493
|
+
try {
|
|
494
|
+
assertSubjectAllowed(input.subject);
|
|
495
|
+
const subscription = await config.handlers.subscriptions.findActiveBySubject(input.subject);
|
|
496
|
+
if (!subscription) {
|
|
497
|
+
return successOperation({
|
|
498
|
+
features: {},
|
|
499
|
+
limits: {},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const entitled = ENTITLED_STATUSES.has(subscription.status);
|
|
503
|
+
return successOperation({
|
|
504
|
+
planKey: subscription.planKey,
|
|
505
|
+
status: subscription.status,
|
|
506
|
+
features: entitled ? subscription.features : {},
|
|
507
|
+
limits: entitled ? subscription.limits : {},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
if (error instanceof AuthError) {
|
|
512
|
+
return errorOperation(error);
|
|
513
|
+
}
|
|
514
|
+
return errorOperation(new AuthError("INTERNAL_ERROR", "Unable to resolve entitlements.", 500));
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
handleWebhook: async (input) => {
|
|
518
|
+
let event;
|
|
519
|
+
try {
|
|
520
|
+
event = config.stripe.webhooks.constructEvent(typeof input.rawBody === "string" ? input.rawBody : Buffer.from(input.rawBody), input.stripeSignature, config.webhookSecret);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return errorOperation(new AuthError("STRIPE_WEBHOOK_INVALID", "Invalid Stripe webhook signature.", 400));
|
|
524
|
+
}
|
|
525
|
+
const processed = await config.handlers.events.hasProcessed(event.id);
|
|
526
|
+
if (processed) {
|
|
527
|
+
return successOperation({
|
|
528
|
+
processed: true,
|
|
529
|
+
eventId: event.id,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const processEvent = async () => {
|
|
533
|
+
switch (event.type) {
|
|
534
|
+
case "checkout.session.completed": {
|
|
535
|
+
const session = event.data.object;
|
|
536
|
+
if (typeof session.customer === "string") {
|
|
537
|
+
const metadata = toMetadataRecord(session.metadata);
|
|
538
|
+
const subject = await resolveSubjectFromMetadata(metadata, session.customer);
|
|
539
|
+
const existing = await config.handlers.customers.findByStripeCustomerId(session.customer);
|
|
540
|
+
if (!existing) {
|
|
541
|
+
await createCustomerRecord(subject, session.customer, ctx.now());
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (typeof session.subscription === "string") {
|
|
545
|
+
await upsertSubscriptionSnapshot(await retrieveSubscription(session.subscription));
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
case "customer.subscription.created":
|
|
550
|
+
case "customer.subscription.updated":
|
|
551
|
+
case "customer.subscription.deleted": {
|
|
552
|
+
await upsertSubscriptionSnapshot(event.data.object);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case "invoice.paid":
|
|
556
|
+
case "invoice.payment_failed": {
|
|
557
|
+
const invoice = event.data.object;
|
|
558
|
+
if (typeof invoice.subscription === "string") {
|
|
559
|
+
await upsertSubscriptionSnapshot(await retrieveSubscription(invoice.subscription));
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
default:
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
await config.handlers.events.markProcessed({
|
|
567
|
+
eventId: event.id,
|
|
568
|
+
processedAt: ctx.now(),
|
|
569
|
+
type: event.type,
|
|
570
|
+
});
|
|
571
|
+
return successOperation({
|
|
572
|
+
processed: true,
|
|
573
|
+
eventId: event.id,
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
if (ctx.adapters.withTransaction) {
|
|
577
|
+
return ctx.adapters.withTransaction(processEvent);
|
|
578
|
+
}
|
|
579
|
+
return processEvent();
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
582
|
+
};
|
|
583
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import { createTOTPKeyURI, generateTOTP, verifyTOTPWithGracePeriod } from "@oslojs/otp";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { addSeconds, createId, createToken, secretHash } from "../core/utils.js";
|
|
3
4
|
import { AuthError } from "../errors/index.js";
|
|
4
5
|
import { createIssue } from "../issues/index.js";
|
|
5
|
-
import { addSeconds, createId, createToken, secretHash } from "../core/utils.js";
|
|
6
6
|
import { errorOperation, successOperation } from "../types/results.js";
|
|
7
7
|
const TOTP_INTERVAL_SECONDS = 30;
|
|
8
8
|
const TOTP_DIGITS = 6;
|
|
@@ -34,7 +34,7 @@ export const twoFactorPlugin = (config) => {
|
|
|
34
34
|
return {
|
|
35
35
|
kind: "domain",
|
|
36
36
|
method: "two_factor",
|
|
37
|
-
version: "
|
|
37
|
+
version: "2.0.0",
|
|
38
38
|
createApi: (ctx) => ({
|
|
39
39
|
evaluatePrimary: async (input, request) => {
|
|
40
40
|
const mustRequire = config.shouldRequire
|
|
@@ -102,9 +102,6 @@ export const twoFactorPlugin = (config) => {
|
|
|
102
102
|
]));
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
else {
|
|
106
|
-
return errorOperation(new AuthError("TWO_FACTOR_INVALID", `Second factor ${input.method} is not implemented in this plugin instance.`, 400));
|
|
107
|
-
}
|
|
108
105
|
const consumedChallenge = await config.challenges.consume(challenge.id);
|
|
109
106
|
if (!consumedChallenge) {
|
|
110
107
|
return errorOperation(new AuthError("TWO_FACTOR_INVALID", "Two-factor challenge already consumed.", 400));
|