Files
kan/packages/auth/src/plugins.ts

318 lines
11 KiB
TypeScript

import { stripe } from "@better-auth/stripe";
import { apiKey, genericOAuth } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { createLogger } from "@kan/logger";
import { generateUID } from "@kan/shared/utils";
import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers";
import { triggerWorkflow } from "./utils";
const log = createLogger("auth");
async function cancelWorkspaceAccess(
db: dbClient,
workspacePublicId: string,
): Promise<void> {
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
if (!workspace) return;
const preserveUserId = await memberRepo.getPreservableMemberId(
db,
workspace.id,
workspace.createdBy ?? null,
);
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await Promise.all([
preserveUserId
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
: memberRepo.pauseAllMembers(db, workspace.id),
workspaceRepo.update(db, workspacePublicId, {
plan: "free",
slug: newSlug,
}),
]);
}
export function createPlugins(db: dbClient) {
return [
socialProvidersPlugin(),
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
? [
stripe({
stripeClient: createStripeClient(),
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "team",
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription);
},
onTrialEnd: async ({ subscription }) => {
await triggerWorkflow(db, "trial-end", subscription);
},
onTrialExpired: async (subscription) => {
await triggerWorkflow(db, "trial-expired", subscription);
},
},
},
{
name: "pro",
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription);
},
onTrialEnd: async ({ subscription }) => {
await triggerWorkflow(db, "trial-end", subscription);
},
onTrialExpired: async (subscription) => {
await triggerWorkflow(db, "trial-expired", subscription);
},
},
},
],
authorizeReference: async (data) => {
const workspace = await workspaceRepo.getByPublicId(
db,
data.referenceId,
);
if (!workspace) {
return Promise.resolve(false);
}
const isUserInWorkspace = await workspaceRepo.isUserInWorkspace(
db,
data.user.id,
workspace.id,
);
return isUserInWorkspace;
},
getCheckoutSessionParams: () => {
return {
params: {
allow_promotion_codes: true,
},
};
},
onSubscriptionComplete: async ({
subscription,
stripeSubscription,
}) => {
// Set unlimited seats to true for pro plans
if (subscription.plan === "pro") {
await subscriptionRepo.updateByStripeSubscriptionId(
db,
stripeSubscription.id,
{
unlimitedSeats: true,
},
);
log.info(
{ subscriptionId: stripeSubscription.id },
"Pro subscription activated with unlimited seats",
);
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.unpauseAllMembers(db, workspace.id);
}
}
},
onSubscriptionCancel: async ({
subscription,
cancellationDetails,
}) => {
await triggerWorkflow(
db,
"subscription-canceled",
subscription,
cancellationDetails,
);
},
onSubscriptionDeleted: async ({ subscription }) => {
await cancelWorkspaceAccess(db, subscription.referenceId);
},
onSubscriptionUpdate: async ({ subscription }) => {
await triggerWorkflow(db, "subscription-updated", subscription);
},
},
}),
]
: []),
apiKey({
enableSessionForAPIKeys: true,
customAPIKeyGetter: (ctx) => {
const authorization = ctx.headers?.get("authorization");
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? null;
},
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute
maxRequests: 100, // 100 requests per minute
},
}),
magicLink({
expiresIn: 60 * 60 * 24 * 7, // 7 days
sendMagicLink: async ({ email, url }) => {
try {
const decodedUrl = decodeURIComponent(url);
log.info(
{ email, isInvite: decodedUrl.includes("type=invite") },
"Sending magic link",
);
if (decodedUrl.includes("type=invite")) {
let inviterName = "";
let workspaceName = "";
try {
const urlObj = new URL(url);
const callbackUrl = urlObj.searchParams.get("callbackURL");
if (callbackUrl) {
const callbackParams = new URL(
callbackUrl,
process.env.NEXT_PUBLIC_BASE_URL,
).searchParams;
const memberPublicId = callbackParams.get("memberPublicId");
if (memberPublicId) {
const member = await memberRepo.getByPublicId(
db,
memberPublicId,
);
if (member) {
const [workspace, inviter] = await Promise.all([
workspaceRepo.getById(db, member.workspaceId),
userRepo.getById(db, member.createdBy),
]);
if (workspace) workspaceName = workspace.name;
if (inviter) inviterName = inviter.name ?? "";
}
}
}
} catch (error) {
log.error({ err: error }, "Failed to fetch invite details");
}
await sendEmail(
email,
workspaceName
? `Invitation to join the workspace ${workspaceName}`
: "Invitation to join workspace",
"JOIN_WORKSPACE",
{
magicLoginUrl: url,
inviterName,
workspaceName,
},
);
} else {
await sendEmail(
email,
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
? "Sign in to your account"
: "Sign in to Kan",
"MAGIC_LINK",
{
magicLoginUrl: url,
},
);
}
} catch (error) {
log.error({ err: error, email }, "Error sending magic link");
}
},
}),
// Generic OIDC provider
...(process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
scopes: ["openid", "email", "profile"],
pkce: true,
mapProfileToUser: (profile: {
name?: string;
display_name?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
email?: string;
email_verified?: boolean;
sub?: string;
picture?: string;
avatar?: string;
}) => {
log.debug({ profile }, "OIDC profile received");
const name =
profile.name ??
profile.display_name ??
profile.preferred_username ??
(profile.given_name && profile.family_name
? `${profile.given_name} ${profile.family_name}`.trim()
: (profile.given_name ?? profile.family_name)) ??
profile.sub ??
"";
return {
email: profile.email,
name: name,
emailVerified: profile.email_verified ?? false,
image: profile.picture ?? profile.avatar ?? null,
};
},
},
],
}),
]
: []),
];
}