From f975633bafaa6a89142d1f6d177375380ccbdbec Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 16 Jan 2026 15:13:37 +0000 Subject: [PATCH] chore: refactor auth --- packages/auth/src/auth.ts | 556 +-------------------------------- packages/auth/src/client.ts | 2 +- packages/auth/src/hooks.ts | 183 +++++++++++ packages/auth/src/plugins.ts | 225 +++++++++++++ packages/auth/src/providers.ts | 107 +++++++ packages/auth/src/utils.ts | 49 +++ 6 files changed, 575 insertions(+), 547 deletions(-) create mode 100644 packages/auth/src/hooks.ts create mode 100644 packages/auth/src/plugins.ts create mode 100644 packages/auth/src/providers.ts create mode 100644 packages/auth/src/utils.ts diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 38114284..4eec3d70 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,147 +1,23 @@ -import type { Subscription } from "@better-auth/stripe"; -import type Stripe from "stripe"; -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { stripe } from "@better-auth/stripe"; -import { ChatOrPushProviderEnum } from "@novu/api/models/components"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; -import { apiKey, genericOAuth } from "better-auth/plugins"; -import { magicLink } from "better-auth/plugins/magic-link"; -import { socialProviderList } from "better-auth/social-providers"; import { env } from "next-runtime-env"; 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 * as schema from "@kan/db/schema"; -import { notificationClient, sendEmail } from "@kan/email"; -import { createEmailUnsubscribeLink } from "@kan/shared"; -import { createStripeClient } from "@kan/stripe"; +import { sendEmail } from "@kan/email"; -export const configuredProviders = socialProviderList.reduce< - Record< - string, - { - clientId: string; - clientSecret: string; - appBundleIdentifier?: string; - tenantId?: string; - requireSelectAccount?: boolean; - clientKey?: string; - issuer?: string; - // Google-specific optional hints - hostedDomain?: string; - hd?: string; - } - > ->((acc, provider) => { - const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`]; - const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`]; - if (id && id.length > 0 && secret && secret.length > 0) { - acc[provider] = { clientId: id, clientSecret: secret }; - } - if ( - provider === "apple" && - Object.keys(acc).includes("apple") && - acc[provider] - ) { - const bundleId = - process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`]; - if (bundleId && bundleId.length > 0) { - acc[provider].appBundleIdentifier = bundleId; - } - } - if ( - provider === "gitlab" && - Object.keys(acc).includes("gitlab") && - acc[provider] - ) { - const issuer = process.env[`${provider.toUpperCase()}_ISSUER`]; - if (issuer && issuer.length > 0) { - acc[provider].issuer = issuer; - } - } - if ( - provider === "microsoft" && - Object.keys(acc).includes("microsoft") && - acc[provider] - ) { - acc[provider].tenantId = "common"; - acc[provider].requireSelectAccount = true; - } - // Add Google domain hint if allowed domains is configured - if ( - provider === "google" && - Object.keys(acc).includes("google") && - acc[provider] - ) { - const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",") - .map((d) => d.trim().toLowerCase()) - .filter(Boolean); - if (allowed && allowed.length > 0) { - // Use the first domain as an authorization hint - acc[provider].hostedDomain = allowed[0]; - acc[provider].hd = allowed[0]; - } - } - if ( - provider === "tiktok" && - Object.keys(acc).includes("tiktok") && - acc[provider] - ) { - const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`]; - if (key && key.length > 0) { - acc[provider].clientKey = key; - } - } - return acc; -}, {}); - -export const socialProvidersPlugin = () => ({ - id: "social-providers-plugin", - endpoints: { - getSocialProviders: createAuthEndpoint( - "/social-providers", - { - method: "GET", - }, - async (ctx) => { - const providers = ctx.context.socialProviders.map((p) => - p.id.toLowerCase(), - ); - // Add OIDC provider if configured - if ( - process.env.OIDC_CLIENT_ID && - process.env.OIDC_CLIENT_SECRET && - process.env.OIDC_DISCOVERY_URL - ) { - providers.push("oidc"); - } - return ctx.json(providers); - }, - ), - }, -}); - -async function downloadImage(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to download image: ${response.statusText}`); - } - return Buffer.from(await response.arrayBuffer()); -} +import { createDatabaseHooks, createMiddlewareHooks } from "./hooks"; +import { createPlugins } from "./plugins"; +import { configuredProviders } from "./providers"; export const initAuth = (db: dbClient) => { return betterAuth({ - secret: process.env.BETTER_AUTH_SECRET!, + secret: env("BETTER_AUTH_SECRET"), baseURL: env("NEXT_PUBLIC_BASE_URL"), - trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS + trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS") ? [ env("NEXT_PUBLIC_BASE_URL") ?? "", - ...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","), + ...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []), ] : [env("NEXT_PUBLIC_BASE_URL") ?? ""], database: drizzleAdapter(db, { @@ -181,387 +57,9 @@ export const initAuth = (db: dbClient) => { }, }, }, - plugins: [ - 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, - }, - ); - console.log( - `Pro subscription ${stripeSubscription.id} 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, - ); - - // for cancelled subscriptions, we need to pause all members and set their workspace plan to free - const workspace = await workspaceRepo.getByPublicId( - db, - subscription.referenceId, - ); - - if (workspace?.id) { - await memberRepo.pauseAllMembers(db, workspace.id); - await workspaceRepo.update(db, subscription.referenceId, { - plan: "free", - }); - } - }, - onSubscriptionUpdate: async ({ subscription }) => { - await triggerWorkflow( - db, - "subscription-updated", - subscription, - ); - }, - }, - }), - ] - : []), - apiKey({ - enableSessionForAPIKeys: true, - 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 }) => { - if (url.includes("type=invite")) { - await sendEmail( - email, - "Invitation to join workspace", - "JOIN_WORKSPACE", - { - magicLoginUrl: url, - }, - ); - } else { - await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", { - magicLoginUrl: url, - }); - } - }, - }), - // 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; - }) => { - console.log("OIDC profile:", profile); - - 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, - }; - }, - }, - ], - }), - ] - : []), - ], - databaseHooks: { - user: { - create: { - async before(user) { - if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") { - const pendingInvitation = await memberRepo.getByEmailAndStatus( - db, - user.email, - "invited", - ); - - if (!pendingInvitation) { - return Promise.resolve(false); - } - - // Fall through to any additional checks below - } - // Enforce allowed domains (OIDC/social) if configured - const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",") - .map((d) => d.trim().toLowerCase()) - .filter(Boolean); - if (allowed && allowed.length > 0) { - const domain = user.email.split("@")[1]?.toLowerCase(); - if (!domain || !allowed.includes(domain)) { - return Promise.resolve(false); - } - } - return Promise.resolve(true); - }, - async after(user) { - let avatarKey = user.image; - if ( - user.image && - !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!) - ) { - try { - const credentials = - env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY") - ? { - accessKeyId: env("S3_ACCESS_KEY_ID")!, - secretAccessKey: env("S3_SECRET_ACCESS_KEY")!, - } - : undefined; - - const client = new S3Client({ - region: env("S3_REGION") ?? "", - endpoint: env("S3_ENDPOINT") ?? "", - forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true", - credentials, - }); - - const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; - - const fileExtension = - user.image.split(".").pop()?.split("?")[0] || "jpg"; - const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`; - - const imageBuffer = await downloadImage(user.image); - - await client.send( - new PutObjectCommand({ - Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", - Key: key, - Body: imageBuffer, - ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`, - ACL: "public-read", - }), - ); - - avatarKey = key; - - await userRepo.update(db, user.id, { - image: key, - }); - } catch (error) { - console.error(error); - } - } - - if (notificationClient) { - try { - const [firstName, ...rest] = user.name - .split(" ") - .filter(Boolean); - const lastName = rest.length ? rest.join(" ") : undefined; - const avatarUrl = avatarKey - ? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}` - : undefined; - - const unsubscribeUrl = await createEmailUnsubscribeLink( - user.id, - ); - - await notificationClient.trigger({ - to: { - subscriberId: user.id, - firstName: firstName, - lastName: lastName, - email: user.email, - avatar: avatarUrl, - data: { - emailVerified: user.emailVerified, - stripeCustomerId: user.stripeCustomerId, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }, - }, - payload: { - emailUnsubscribeUrl: unsubscribeUrl, - }, - workflowId: "user-signup", - }); - - await notificationClient.subscribers.credentials.update( - { - providerId: ChatOrPushProviderEnum.Discord, - credentials: { - webhookUrl: process.env.DISCORD_WEBHOOK_URL!, - }, - integrationIdentifier: "discord", - }, - user.id, - ); - } catch (error) { - console.error( - "Error adding user to notification client", - error, - ); - } - } - }, - }, - }, - }, - hooks: { - after: createAuthMiddleware(async (ctx) => { - if ( - ctx.path === "/magic-link/verify" && - (ctx.query?.callbackURL as string | undefined)?.includes( - "type=invite", - ) - ) { - const userId = ctx.context.newSession?.session.userId; - const callbackURL = ctx.query?.callbackURL as string | undefined; - const memberPublicId = callbackURL?.split("memberPublicId=")[1]; - - if (userId && memberPublicId) { - const member = await memberRepo.getByPublicId(db, memberPublicId); - - if (member?.id) { - await memberRepo.acceptInvite(db, { - memberId: member.id, - userId, - }); - } - } - } - }), - }, + plugins: createPlugins(db), + databaseHooks: createDatabaseHooks(db), + hooks: createMiddlewareHooks(db), advanced: { cookiePrefix: "kan", database: { @@ -570,37 +68,3 @@ export const initAuth = (db: dbClient) => { }, }); }; - -async function triggerWorkflow( - db: dbClient, - workflowId: string, - subscription: Subscription, - cancellationDetails?: Stripe.Subscription.CancellationDetails | null, -) { - try { - if (!subscription.stripeCustomerId || !notificationClient) return; - - const user = await userRepo.getByStripeCustomerId( - db, - subscription.stripeCustomerId, - ); - - if (!user || !notificationClient) return; - - const unsubscribeUrl = await createEmailUnsubscribeLink(user.id); - - await notificationClient.trigger({ - to: { - subscriberId: user.id, - }, - payload: { - ...subscription, - cancellationDetails, - emailUnsubscribeUrl: unsubscribeUrl, - }, - workflowId, - }); - } catch (error) { - console.error("Error triggering workflow", error); - } -} diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 8f2b2bc4..e62498c0 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -8,7 +8,7 @@ import { } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; -import type { socialProvidersPlugin } from "./auth"; +import type { socialProvidersPlugin } from "./providers"; const socialProvidersPluginClient = { id: "social-providers-plugin", diff --git a/packages/auth/src/hooks.ts b/packages/auth/src/hooks.ts new file mode 100644 index 00000000..c357681f --- /dev/null +++ b/packages/auth/src/hooks.ts @@ -0,0 +1,183 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { ChatOrPushProviderEnum } from "@novu/api/models/components"; +import { createAuthMiddleware } from "better-auth/api"; +import { env } from "next-runtime-env"; + +import type { dbClient } from "@kan/db/client"; +import * as memberRepo from "@kan/db/repository/member.repo"; +import * as userRepo from "@kan/db/repository/user.repo"; +import { notificationClient } from "@kan/email"; +import { createEmailUnsubscribeLink } from "@kan/shared"; + +import { downloadImage } from "./utils"; + +type BetterAuthUser = { + id: string; + createdAt: Date; + updatedAt: Date; + email: string; + emailVerified: boolean; + name: string; + image?: string | null | undefined; + stripeCustomerId?: string | null | undefined; +} & Record; + +export function createDatabaseHooks(db: dbClient) { + return { + user: { + create: { + async before(user: BetterAuthUser, _context: unknown) { + if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") { + const pendingInvitation = await memberRepo.getByEmailAndStatus( + db, + user.email, + "invited", + ); + + if (!pendingInvitation) { + return Promise.resolve(false); + } + + // Fall through to any additional checks below + } + // Enforce allowed domains (OIDC/social) if configured + const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",") + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); + if (allowed && allowed.length > 0) { + const domain = user.email.split("@")[1]?.toLowerCase(); + if (!domain || !allowed.includes(domain)) { + return Promise.resolve(false); + } + } + return Promise.resolve(true); + }, + async after(user: BetterAuthUser, _context: unknown) { + let avatarKey = user.image; + const storageDomain = process.env.NEXT_PUBLIC_STORAGE_DOMAIN; + if ( + user.image && + storageDomain && + !user.image.includes(storageDomain) + ) { + try { + const credentials = + env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY") + ? { + accessKeyId: env("S3_ACCESS_KEY_ID")!, + secretAccessKey: env("S3_SECRET_ACCESS_KEY")!, + } + : undefined; + + const client = new S3Client({ + region: env("S3_REGION") ?? "", + endpoint: env("S3_ENDPOINT") ?? "", + forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true", + credentials, + }); + + const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; + + const fileExtension = + user.image.split(".").pop()?.split("?")[0] ?? "jpg"; + const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`; + + const imageBuffer = await downloadImage(user.image); + + await client.send( + new PutObjectCommand({ + Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", + Key: key, + Body: imageBuffer, + ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`, + ACL: "public-read", + }), + ); + + avatarKey = key; + + await userRepo.update(db, user.id, { + image: key, + }); + } catch (error) { + console.error(error); + } + } + + if (notificationClient) { + try { + const [firstName, ...rest] = (user.name || "") + .split(" ") + .filter(Boolean); + const lastName = rest.length ? rest.join(" ") : undefined; + const avatarUrl = avatarKey + ? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}` + : undefined; + + const unsubscribeUrl = await createEmailUnsubscribeLink(user.id); + + await notificationClient.trigger({ + to: { + subscriberId: user.id, + firstName: firstName, + lastName: lastName, + email: user.email, + avatar: avatarUrl, + data: { + emailVerified: user.emailVerified, + stripeCustomerId: user.stripeCustomerId, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + }, + payload: { + emailUnsubscribeUrl: unsubscribeUrl, + }, + workflowId: "user-signup", + }); + + await notificationClient.subscribers.credentials.update( + { + providerId: ChatOrPushProviderEnum.Discord, + credentials: { + webhookUrl: env("DISCORD_WEBHOOK_URL"), + }, + integrationIdentifier: "discord", + }, + user.id, + ); + } catch (error) { + console.error("Error adding user to notification client", error); + } + } + }, + }, + }, + }; +} + +export function createMiddlewareHooks(db: dbClient) { + return { + after: createAuthMiddleware(async (ctx) => { + if ( + ctx.path === "/magic-link/verify" && + (ctx.query?.callbackURL as string | undefined)?.includes("type=invite") + ) { + const userId = ctx.context.newSession?.session.userId; + const callbackURL = ctx.query?.callbackURL as string | undefined; + const memberPublicId = callbackURL?.split("memberPublicId=")[1]; + + if (userId && memberPublicId) { + const member = await memberRepo.getByPublicId(db, memberPublicId); + + if (member?.id) { + await memberRepo.acceptInvite(db, { + memberId: member.id, + userId, + }); + } + } + } + }), + }; +} diff --git a/packages/auth/src/plugins.ts b/packages/auth/src/plugins.ts new file mode 100644 index 00000000..948d3530 --- /dev/null +++ b/packages/auth/src/plugins.ts @@ -0,0 +1,225 @@ +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 workspaceRepo from "@kan/db/repository/workspace.repo"; +import { sendEmail } from "@kan/email"; +import { createStripeClient } from "@kan/stripe"; + +import { socialProvidersPlugin } from "./providers"; +import { triggerWorkflow } from "./utils"; + +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, + }, + ); + console.log( + `Pro subscription ${stripeSubscription.id} 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, + ); + + // for cancelled subscriptions, we need to pause all members and set their workspace plan to free + const workspace = await workspaceRepo.getByPublicId( + db, + subscription.referenceId, + ); + + if (workspace?.id) { + await memberRepo.pauseAllMembers(db, workspace.id); + await workspaceRepo.update(db, subscription.referenceId, { + plan: "free", + }); + } + }, + onSubscriptionUpdate: async ({ subscription }) => { + await triggerWorkflow(db, "subscription-updated", subscription); + }, + }, + }), + ] + : []), + apiKey({ + enableSessionForAPIKeys: true, + 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 }) => { + if (url.includes("type=invite")) { + await sendEmail( + email, + "Invitation to join workspace", + "JOIN_WORKSPACE", + { + magicLoginUrl: url, + }, + ); + } else { + await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", { + magicLoginUrl: url, + }); + } + }, + }), + // 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; + }) => { + console.log("OIDC profile:", profile); + + 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, + }; + }, + }, + ], + }), + ] + : []), + ]; +} diff --git a/packages/auth/src/providers.ts b/packages/auth/src/providers.ts new file mode 100644 index 00000000..721067bc --- /dev/null +++ b/packages/auth/src/providers.ts @@ -0,0 +1,107 @@ +import { createAuthEndpoint } from "better-auth/api"; +import { socialProviderList } from "better-auth/social-providers"; + +export const configuredProviders = socialProviderList.reduce< + Record< + string, + { + clientId: string; + clientSecret: string; + appBundleIdentifier?: string; + tenantId?: string; + requireSelectAccount?: boolean; + clientKey?: string; + issuer?: string; + // Google-specific optional hints + hostedDomain?: string; + hd?: string; + } + > +>((acc, provider) => { + const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`]; + const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`]; + if (id && id.length > 0 && secret && secret.length > 0) { + acc[provider] = { clientId: id, clientSecret: secret }; + } + if ( + provider === "apple" && + Object.keys(acc).includes("apple") && + acc[provider] + ) { + const bundleId = + process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`]; + if (bundleId && bundleId.length > 0) { + acc[provider].appBundleIdentifier = bundleId; + } + } + if ( + provider === "gitlab" && + Object.keys(acc).includes("gitlab") && + acc[provider] + ) { + const issuer = process.env[`${provider.toUpperCase()}_ISSUER`]; + if (issuer && issuer.length > 0) { + acc[provider].issuer = issuer; + } + } + if ( + provider === "microsoft" && + Object.keys(acc).includes("microsoft") && + acc[provider] + ) { + acc[provider].tenantId = "common"; + acc[provider].requireSelectAccount = true; + } + // Add Google domain hint if allowed domains is configured + if ( + provider === "google" && + Object.keys(acc).includes("google") && + acc[provider] + ) { + const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",") + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); + if (allowed && allowed.length > 0) { + // Use the first domain as an authorization hint + acc[provider].hostedDomain = allowed[0]; + acc[provider].hd = allowed[0]; + } + } + if ( + provider === "tiktok" && + Object.keys(acc).includes("tiktok") && + acc[provider] + ) { + const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`]; + if (key && key.length > 0) { + acc[provider].clientKey = key; + } + } + return acc; +}, {}); + +export const socialProvidersPlugin = () => ({ + id: "social-providers-plugin", + endpoints: { + getSocialProviders: createAuthEndpoint( + "/social-providers", + { + method: "GET", + }, + async (ctx) => { + const providers = ctx.context.socialProviders.map((p) => + p.id.toLowerCase(), + ); + // Add OIDC provider if configured + if ( + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_DISCOVERY_URL + ) { + providers.push("oidc"); + } + return ctx.json(providers); + }, + ), + }, +}); diff --git a/packages/auth/src/utils.ts b/packages/auth/src/utils.ts new file mode 100644 index 00000000..3188b554 --- /dev/null +++ b/packages/auth/src/utils.ts @@ -0,0 +1,49 @@ +import type { Subscription } from "@better-auth/stripe"; +import type Stripe from "stripe"; + +import type { dbClient } from "@kan/db/client"; +import * as userRepo from "@kan/db/repository/user.repo"; +import { notificationClient } from "@kan/email"; +import { createEmailUnsubscribeLink } from "@kan/shared"; + +export async function downloadImage(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +export async function triggerWorkflow( + db: dbClient, + workflowId: string, + subscription: Subscription, + cancellationDetails?: Stripe.Subscription.CancellationDetails | null, +) { + try { + if (!subscription.stripeCustomerId || !notificationClient) return; + + const user = await userRepo.getByStripeCustomerId( + db, + subscription.stripeCustomerId, + ); + + if (!user || !notificationClient) return; + + const unsubscribeUrl = await createEmailUnsubscribeLink(user.id); + + await notificationClient.trigger({ + to: { + subscriberId: user.id, + }, + payload: { + ...subscription, + cancellationDetails, + emailUnsubscribeUrl: unsubscribeUrl, + }, + workflowId, + }); + } catch (error) { + console.error("Error triggering workflow", error); + } +}