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
This commit is contained in:
LovelessCodes
2025-06-05 13:00:07 +02:00
committed by GitHub
parent 4b7f58aeeb
commit 6eb17529f1
9 changed files with 219 additions and 44 deletions

View File

@@ -21,3 +21,8 @@ BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

View File

@@ -88,6 +88,10 @@ pnpm dev
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` | | `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_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | | `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_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` | | `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |

View File

@@ -1,8 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; 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 { z } from "zod";
import type { SocialProvider } from "better-auth/social-providers";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
@@ -20,8 +22,9 @@ interface AuthProps {
const EmailSchema = z.object({ email: z.string().email() }); const EmailSchema = z.object({ email: z.string().email() });
export function Auth({ setIsMagicLinkSent }: AuthProps) { export function Auth({ setIsMagicLinkSent }: AuthProps) {
const [isLoginWithGooglePending, setIsLoginWithGooglePending] = const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
useState(false); null | SocialProvider
>(null);
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
@@ -33,6 +36,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
resolver: zodResolver(EmailSchema), resolver: zodResolver(EmailSchema),
}); });
const { data: socialProviders } = useQuery({
queryKey: ["social_providers"],
queryFn: () => authClient.getSocialProviders(),
});
const handleLoginWithEmail = async (email: string) => { const handleLoginWithEmail = async (email: string) => {
setIsLoginWithEmailPending(true); setIsLoginWithEmailPending(true);
setLoginError(null); setLoginError(null);
@@ -52,18 +60,21 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
} }
}; };
const handleLoginWithGoogle = async () => { const handleLoginWithProvider = async (
setIsLoginWithGooglePending(true); provider: SocialProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null); setLoginError(null);
const { error } = await authClient.signIn.social({ const { error } = await authClient.signIn.social({
provider: "google", provider,
callbackURL: "/boards", callbackURL: "/boards",
}); });
setIsLoginWithGooglePending(false); setIsLoginWithProviderPending(null);
if (error) { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> {socialProviders?.length !== 0 && (
<Button <div className="space-y-2">
onClick={handleLoginWithGoogle} {socialProviders?.includes("google") && (
isLoading={isLoginWithGooglePending} <Button
iconLeft={<FaGoogle />} onClick={() => handleLoginWithProvider("google")}
fullWidth isLoading={isLoginWithProviderPending === "google"}
size="lg" iconLeft={<FaGoogle />}
> fullWidth
Continue with Google size="lg"
</Button> >
</div> Continue with Google
<form onSubmit={handleSubmit(onSubmit)}> </Button>
<div className="mb-[1.5rem] flex w-full items-center gap-4"> )}
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> {socialProviders?.includes("github") && (
<span className="text-sm text-light-900 dark:text-dark-900">or</span> <Button
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> onClick={() => handleLoginWithProvider("github")}
isLoading={isLoginWithProviderPending === "github"}
iconLeft={<FaGithub />}
fullWidth
size="lg"
>
Continue with GitHub
</Button>
)}
{socialProviders?.includes("discord") && (
<Button
onClick={() => handleLoginWithProvider("discord")}
isLoading={isLoginWithProviderPending === "discord"}
iconLeft={<FaDiscord />}
fullWidth
size="lg"
>
Continue with Discord
</Button>
)}
</div> </div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
or
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<Input <Input
{...register("email", { required: true })} {...register("email", { required: true })}
placeholder="Enter your email address" placeholder="Enter your email address"

View File

@@ -21,6 +21,10 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(), S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(), S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(), S3_REGION: z.string().optional(),

View File

@@ -27,6 +27,10 @@ services:
- POSTGRES_URL=${POSTGRES_URL} - POSTGRES_URL=${POSTGRES_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}

View File

@@ -1,6 +1,6 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
import { apiKey } from "better-auth/plugins"; import { apiKey } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link"; import { magicLink } from "better-auth/plugins/magic-link";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
@@ -11,6 +11,81 @@ import * as userRepo from "@kan/db/repository/user.repo";
import * as schema from "@kan/db/schema"; import * as schema from "@kan/db/schema";
import { sendEmail } from "@kan/email"; import { sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
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;
}
>
>((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) => { export const initAuth = (db: dbClient) => {
return betterAuth({ return betterAuth({
@@ -26,12 +101,7 @@ export const initAuth = (db: dbClient) => {
user: schema.users, user: schema.users,
}, },
}), }),
socialProviders: { socialProviders: configuredProviders,
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
user: { user: {
additionalFields: { additionalFields: {
stripeCustomerId: { stripeCustomerId: {
@@ -43,6 +113,7 @@ export const initAuth = (db: dbClient) => {
}, },
}, },
plugins: [ plugins: [
socialProvidersPlugin(),
// @todo: hasing is disabled due to a bug in the api key plugin // @todo: hasing is disabled due to a bug in the api key plugin
apiKey({ disableKeyHashing: true }), apiKey({ disableKeyHashing: true }),
magicLink({ magicLink({

View File

@@ -1,6 +1,25 @@
import { BetterAuthClientPlugin } from "better-auth";
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins"; 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<typeof socialProvidersPlugin>,
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({ export const authClient = createAuthClient({
plugins: [magicLinkClient(), apiKeyClient()], plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
}); });

View File

@@ -1,6 +1,8 @@
{ {
"extends": "@kan/tsconfig/internal-package.json", "extends": "@kan/tsconfig/internal-package.json",
"compilerOptions": {}, "compilerOptions": {
"jsx": "react-jsx"
},
"include": ["*.ts", "src"], "include": ["*.ts", "src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@@ -3,28 +3,49 @@
"ui": "tui", "ui": "tui",
"tasks": { "tasks": {
"topo": { "topo": {
"dependsOn": ["^topo"] "dependsOn": [
"^topo"
]
}, },
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": [
"outputs": [".cache/tsbuildinfo.json", "dist/**"] "^build"
],
"outputs": [
".cache/tsbuildinfo.json",
"dist/**"
]
}, },
"dev": { "dev": {
"dependsOn": ["^dev"], "dependsOn": [
"^dev"
],
"cache": false, "cache": false,
"persistent": false "persistent": false
}, },
"format": { "format": {
"outputs": [".cache/.prettiercache"], "outputs": [
".cache/.prettiercache"
],
"outputLogs": "new-only" "outputLogs": "new-only"
}, },
"lint": { "lint": {
"dependsOn": ["^topo", "^build"], "dependsOn": [
"outputs": [".cache/.eslintcache"] "^topo",
"^build"
],
"outputs": [
".cache/.eslintcache"
]
}, },
"typecheck": { "typecheck": {
"dependsOn": ["^topo", "^build"], "dependsOn": [
"outputs": [".cache/tsbuildinfo.json"] "^topo",
"^build"
],
"outputs": [
".cache/tsbuildinfo.json"
]
}, },
"clean": { "clean": {
"cache": false "cache": false
@@ -49,6 +70,10 @@
"POSTGRES_URL", "POSTGRES_URL",
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET",
"DISCORD_CLIENT_ID",
"DISCORD_CLIENT_SECRET",
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
"EMAIL_FROM", "EMAIL_FROM",
"SMTP_HOST", "SMTP_HOST",
"SMTP_PORT", "SMTP_PORT",
@@ -78,4 +103,4 @@
"VERCEL_URL", "VERCEL_URL",
"npm_lifecycle_event" "npm_lifecycle_event"
] ]
} }