From 6eb17529f1c34e79270053c69e9cbaea9834294b Mon Sep 17 00:00:00 2001 From: LovelessCodes Date: Thu, 5 Jun 2025 13:00:07 +0200 Subject: [PATCH] feat(auth): added discord & github authentication methods (#18) * feat(auth): added discord & github authentication methods * chore(format): formatted edited files * docs: add Discord and GitHub OAuth environment variables to README * feat: add Discord and GitHub OAuth client credentials to environment config * refactor: migrate social provider types to better-auth package * feat: return configured social provider names from auth endpoint * refactor: simplify socialProvidersPlugin by removing nested function and condensing return --- .env.example | 5 ++ README.md | 4 ++ apps/web/src/components/AuthForm.tsx | 89 ++++++++++++++++++++-------- apps/web/src/env.ts | 4 ++ docker-compose.yml | 4 ++ packages/auth/src/auth.ts | 85 +++++++++++++++++++++++--- packages/auth/src/client.ts | 23 ++++++- packages/auth/tsconfig.json | 4 +- turbo.json | 45 ++++++++++---- 9 files changed, 219 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 2e6a1161..38ebc4e1 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ BETTER_AUTH_TRUSTED_ORIGINS= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + diff --git a/README.md b/README.md index f9db4b0e..8117cbbb 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ pnpm dev | `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | +| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` | +| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` | +| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | | `S3_REGION` | S3 storage region | For file uploads | `WEUR` | | `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` | diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx index f53db80c..61e72be3 100644 --- a/apps/web/src/components/AuthForm.tsx +++ b/apps/web/src/components/AuthForm.tsx @@ -1,8 +1,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { useForm } from "react-hook-form"; -import { FaGoogle } from "react-icons/fa"; +import { FaDiscord, FaGithub, FaGoogle } from "react-icons/fa"; import { z } from "zod"; +import type { SocialProvider } from "better-auth/social-providers"; import { authClient } from "@kan/auth/client"; @@ -20,8 +22,9 @@ interface AuthProps { const EmailSchema = z.object({ email: z.string().email() }); export function Auth({ setIsMagicLinkSent }: AuthProps) { - const [isLoginWithGooglePending, setIsLoginWithGooglePending] = - useState(false); + const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState< + null | SocialProvider + >(null); const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [loginError, setLoginError] = useState(null); @@ -33,6 +36,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) { resolver: zodResolver(EmailSchema), }); + const { data: socialProviders } = useQuery({ + queryKey: ["social_providers"], + queryFn: () => authClient.getSocialProviders(), + }); + const handleLoginWithEmail = async (email: string) => { setIsLoginWithEmailPending(true); setLoginError(null); @@ -52,18 +60,21 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) { } }; - const handleLoginWithGoogle = async () => { - setIsLoginWithGooglePending(true); + const handleLoginWithProvider = async ( + provider: SocialProvider) => { + setIsLoginWithProviderPending(provider); setLoginError(null); const { error } = await authClient.signIn.social({ - provider: "google", + provider, callbackURL: "/boards", }); - setIsLoginWithGooglePending(false); + setIsLoginWithProviderPending(null); if (error) { - setLoginError("Failed to login with Google. Please try again."); + setLoginError( + `Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`, + ); } }; @@ -73,23 +84,53 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) { return (
-
- -
-
-
-
- or -
+ {socialProviders?.length !== 0 && ( +
+ {socialProviders?.includes("google") && ( + + )} + {socialProviders?.includes("github") && ( + + )} + {socialProviders?.includes("discord") && ( + + )}
+ )} + + {socialProviders?.length !== 0 && ( +
+
+ + or + +
+
+ )} +>((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; + } + 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) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())), + ), + }, +}); export const initAuth = (db: dbClient) => { return betterAuth({ @@ -26,12 +101,7 @@ export const initAuth = (db: dbClient) => { user: schema.users, }, }), - socialProviders: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }, - }, + socialProviders: configuredProviders, user: { additionalFields: { stripeCustomerId: { @@ -43,6 +113,7 @@ export const initAuth = (db: dbClient) => { }, }, plugins: [ + socialProvidersPlugin(), // @todo: hasing is disabled due to a bug in the api key plugin apiKey({ disableKeyHashing: true }), magicLink({ diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 39e8124b..765f1142 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,6 +1,25 @@ +import { BetterAuthClientPlugin } from "better-auth"; import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins"; -import { createAuthClient } from "better-auth/react"; +import { BetterFetchOption, createAuthClient } from "better-auth/react"; + +import { socialProvidersPlugin } from "./auth"; + +const socialProvidersPluginClient = { + id: "social-providers-plugin", + $InferServerPlugin: {} as ReturnType, + getActions: ($fetch) => { + return { + getSocialProviders: async (fetchOptions?: BetterFetchOption) => { + const res = $fetch("/social-providers", { + method: "GET", + ...fetchOptions, + }); + return res.then((res) => res.data as string[]); + }, + }; + }, +} satisfies BetterAuthClientPlugin; export const authClient = createAuthClient({ - plugins: [magicLinkClient(), apiKeyClient()], + plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient], }); diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 7496acfb..016cb881 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "@kan/tsconfig/internal-package.json", - "compilerOptions": {}, + "compilerOptions": { + "jsx": "react-jsx" + }, "include": ["*.ts", "src"], "exclude": ["node_modules"] } diff --git a/turbo.json b/turbo.json index aebe178e..d14f688e 100644 --- a/turbo.json +++ b/turbo.json @@ -3,28 +3,49 @@ "ui": "tui", "tasks": { "topo": { - "dependsOn": ["^topo"] + "dependsOn": [ + "^topo" + ] }, "build": { - "dependsOn": ["^build"], - "outputs": [".cache/tsbuildinfo.json", "dist/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".cache/tsbuildinfo.json", + "dist/**" + ] }, "dev": { - "dependsOn": ["^dev"], + "dependsOn": [ + "^dev" + ], "cache": false, "persistent": false }, "format": { - "outputs": [".cache/.prettiercache"], + "outputs": [ + ".cache/.prettiercache" + ], "outputLogs": "new-only" }, "lint": { - "dependsOn": ["^topo", "^build"], - "outputs": [".cache/.eslintcache"] + "dependsOn": [ + "^topo", + "^build" + ], + "outputs": [ + ".cache/.eslintcache" + ] }, "typecheck": { - "dependsOn": ["^topo", "^build"], - "outputs": [".cache/tsbuildinfo.json"] + "dependsOn": [ + "^topo", + "^build" + ], + "outputs": [ + ".cache/tsbuildinfo.json" + ] }, "clean": { "cache": false @@ -49,6 +70,10 @@ "POSTGRES_URL", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", + "DISCORD_CLIENT_ID", + "DISCORD_CLIENT_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", "EMAIL_FROM", "SMTP_HOST", "SMTP_PORT", @@ -78,4 +103,4 @@ "VERCEL_URL", "npm_lifecycle_event" ] -} +} \ No newline at end of file