318 lines
11 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
]
|
|
: []),
|
|
];
|
|
}
|