Skip to content

Commit

Permalink
hook implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmytton committed Jan 7, 2025
1 parent a08fc55 commit 03d9e4f
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 42 deletions.
9 changes: 7 additions & 2 deletions examples/nextjs-better-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@

# Arcjet bot detection with Next.js + Better Auth

This example shows how to use Arcjet with a Next.js [route
handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers).
This example shows how to use Arcjet with [Better
Auth](https://www.better-auth.com). Arcjet is implemented as a hook in
`auth.ts`, which is our recommended approach.

Alternatively, you can use the Arcjet integration in
`app/api/auth/[...all]/route.ts`. If you do, remove the hook from `auth.ts` to
avoid duplicate protection.

## How to use

Expand Down
18 changes: 13 additions & 5 deletions examples/nextjs-better-auth/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
// This is an alternative to our recommended approach of implementing the Arcjet
// as a Better Auth hook. Choose one or the other to avoid duplicate
// protections.

import { auth } from "@/auth";
import ip from "@arcjet/ip";
import arcjet, { shield, tokenBucket } from "@arcjet/next";
//import ip from "@arcjet/ip";
//import arcjet, { shield, tokenBucket } from "@arcjet/next";
import { toNextJsHandler } from "better-auth/next-js";
import { NextRequest } from "next/server";
//import { NextRequest } from "next/server";

export const { POST, GET } = toNextJsHandler(auth);

/*
// The arcjet instance is created outside of the handler
const aj = arcjet({
key: process.env.ARCJET_KEY!, // Get your site key from https://app.arcjet.com
Expand All @@ -18,7 +25,7 @@ const aj = arcjet({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
refillRate: 5, // refill 5 tokens per interval
interval: 10, // refill every 10 seconds
capacity: 10, // bucket maximum capacity of 10 tokens
capacity: 50, // bucket maximum capacity of 10 tokens
}),
],
});
Expand Down Expand Up @@ -73,4 +80,5 @@ const ajProtectedPOST = async (req: NextRequest) => {
}
export { ajProtectedPOST as POST };
export const { GET } = betterAuthHandlers;
export const { GET } = betterAuthHandlers;
*/
Binary file removed examples/nextjs-better-auth/app/favicon.ico
Binary file not shown.
27 changes: 0 additions & 27 deletions examples/nextjs-better-auth/app/globals.css

This file was deleted.

6 changes: 1 addition & 5 deletions examples/nextjs-better-auth/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
title: 'Create Next App',
Expand All @@ -16,7 +12,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body>{children}</body>
</html>
)
}
8 changes: 7 additions & 1 deletion examples/nextjs-better-auth/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { auth } from "@/auth"
import { headers } from "next/headers"
import SignInButton from '@/components/SignInGitHub'
import SignOutButton from "@/components/SignOut"
import SignUp from "@/components/SignUp"

export default async function Home() {
const session = await auth.api.getSession({
headers: await headers()
})

if (!session) {
return <div>Not authenticated. <SignInButton /></div>
return (
<>
<div>Not authenticated. <SignInButton /> or sign up below.</div>
<div><SignUp /></div>
</>
)
}

return (
Expand Down
126 changes: 124 additions & 2 deletions examples/nextjs-better-auth/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,137 @@
// We recommend implementing Arcjet here as a hook. However, you can also
// implement it in the route handler at app/api/auth/[...all]/route.ts. Pick one
// and delete the other so you don't have duplicate protections.

import arcjet, { detectBot, protectSignup, request, shield, slidingWindow, type ArcjetDecision } from "@arcjet/next";
import { betterAuth } from "better-auth";
import { APIError, createAuthMiddleware } from "better-auth/api";
import Database from "better-sqlite3";

// The arcjet instance is created outside of the handler
const aj = arcjet({
key: process.env.ARCJET_KEY!, // Get your site key from https://app.arcjet.com
characteristics: ["userId"],
rules: [
// Protect against common attacks with Arcjet Shield. Other rules are
// added dynamically using `withRule`.
shield({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
}),
],
});

export const auth = betterAuth({
database: new Database("./sqlite.db"),
emailAndPassword: {
enabled: false
enabled: true
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
},
})
hooks: {
// Run on every auth request
before: createAuthMiddleware(async (ctx) => {
let decision: ArcjetDecision;
const req = await request(); // Gets the request object

// If the user is logged in we'll use their ID as the identifier. This
// allows limits to be applied across all devices and sessions (you could
// also use the session ID). Otherwise, fall back to the IP address.
let userId: string;
if (ctx.context.session?.user.id) {
userId = ctx.context.session?.user.id;
} else {
userId = req.ip || "127.0.0.1"; // Fall back to local IP if none
}

// If this is a signup then use the special protectSignup rule
// See https://docs.arcjet.com/signup-protection/quick-start
if (ctx.path.startsWith("/sign-up")) {
// If the email is in the body of the request then we can run
// the email validation checks as well. See
// https://www.better-auth.com/docs/concepts/hooks#example-enforce-email-domain-restriction
if (ctx.body?.email) {
decision = await aj.withRule(
protectSignup({
email: {
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
// Block emails that are disposable, invalid, or have no MX records
block: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS"],
},
bots: {
mode: "LIVE",
// configured with a list of bots to allow from
// https://arcjet.com/bot-list
allow: [], // prevents bots from submitting the form
},
// It would be unusual for a form to be submitted more than 5 times in 10
// minutes from the same IP address
rateLimit: {
// uses a sliding window rate limit
mode: "LIVE",
interval: "2m", // counts requests over a 10 minute sliding window
max: 5, // allows 5 submissions within the window
},
})).protect(req, { email: ctx.body.email, userId });
} else {
// Otherwise use rate limit and detect bot
decision = await aj.withRule(
detectBot({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
// configured with a list of bots to allow from
// https://arcjet.com/bot-list
allow: [], // blocks all automated clients
}))
.withRule(
slidingWindow({
mode: "LIVE",
interval: "2m", // counts requests over a 1 minute sliding window
max: 5, // allows 5 requests within the window
}))
.protect(req, { userId });
}
} else {
// For all other auth requests
decision = await aj
.withRule(
detectBot({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
// configured with a list of bots to allow from
// https://arcjet.com/bot-list
allow: [], // blocks all automated clients
}))
.protect(req, { userId });
}

console.log("Arcjet Decision:", decision);

if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
throw new APIError("TOO_MANY_REQUESTS");
} else if (decision.reason.isEmail()) {
let message: string;

if (decision.reason.emailTypes.includes("INVALID")) {
message = "Email address format is invalid. Is there a typo?";
} else if (decision.reason.emailTypes.includes("DISPOSABLE")) {
message = "We do not allow disposable email addresses.";
} else if (decision.reason.emailTypes.includes("NO_MX_RECORDS")) {
message =
"Your email domain does not have an MX record. Is there a typo?";
} else {
// This is a catch all, but the above should be exhaustive based on the
// configured rules.
message = "Invalid email.";
}

throw new APIError("BAD_REQUEST", { message });
} else {
throw new APIError("FORBIDDEN");
}
}
}),
},
});
42 changes: 42 additions & 0 deletions examples/nextjs-better-auth/components/SignUp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client"
import { authClient } from "@/auth-client"; //import the auth client
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function SignUp() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const router = useRouter();

const signUp = async () => {
const { data, error } = await authClient.signUp.email({
email,
password,
name
}, {
onRequest: (ctx) => {
//show loading
},
onSuccess: (ctx) => {
router.push("/");
},
onError: (ctx) => {
alert(ctx.error.message);
},
});
};

return (
<div>
<h2>Sign up</h2>
<label htmlFor="name">Name</label>
<input type="name" value={name} onChange={(e) => setName(e.target.value)} /><br />
<label htmlFor="password">Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /><br />
<label htmlFor="email">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /><br />
<button onClick={signUp}>Sign Up</button>
</div>
);
}

0 comments on commit 03d9e4f

Please sign in to comment.