How to build an authentication system from scratch
Learn how to build an authentication system from scratch using Arctic for OAuth.
- Auth
- OAuth
- Arctic
Introduction
Building an authentication system from scratch can be a daunting task, but with the right tools and techniques, it can be a rewarding experience. In this guide, we'll walk through how to build an authentication system from scratch using Arctic for OAuth.
What is Arctic?
Arctic is a collection of OAuth 2.0 clients for popular providers. Only the authorization code flow is supported. Built on top of the Fetch API, it's light weight, fully-typed, and runtime-agnostic.
Getting Started
To get started with Arctic, you can install using following command:
npm install @oslojs/crypto @oslojs/encoding arctic @tanstack/react-query
pnpm add @oslojs/crypto @oslojs/encoding arctic @tanstack/react-query
yarn add @oslojs/crypto @oslojs/encoding arctic @tanstack/react-query
bun add @oslojs/crypto @oslojs/encoding arctic @tanstack/react-query
npm install -d prisma
pnpm add -d prisma
yarn add -d prisma
bun add -d prisma
Setting up the Prisma schema
First, let's set up the Prisma schema. Create a new file called schema.prisma
and add the following code:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
image String
password String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
provider String
providerAccountId String
providerAccountName String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @id
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
}
Then, add some scripts to your package.json
:
{
...
"scripts": {
...
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio"
}
}
Now, run the following command to create the database:
Remember to set the DATABASE_URL
environment variable.
bun db:push
Next, create the Prisma
function in server/db.ts
:
import { PrismaClient } from '@prisma/client'
const createPrismaClient = () =>
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
})
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof createPrismaClient> | undefined
}
export const db = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
Create session class
Next, let's create a class called Session
that handles session management. Create a new file called server/auth/session.ts
and add the following code:
import type { User } from '@prisma/client'
import { sha256 } from '@oslojs/crypto/sha2'
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from '@oslojs/encoding'
import { db } from '@yuki/db'
export class Session {
private readonly db: typeof db
private readonly EXPIRATION_TIME
constructor() {
this.EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days
this.db = db
}
private generateSessionToken(): string {
const bytes = new Uint8Array(20)
crypto.getRandomValues(bytes)
const token = encodeBase32LowerCaseNoPadding(bytes)
return token
}
public async createSession(
userId: User['id'],
): Promise<{ sessionToken: string; expires: Date }> {
const token = this.generateSessionToken()
const session = await this.db.session.create({
data: {
sessionToken: encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
),
expires: new Date(Date.now() + this.EXPIRATION_TIME),
userId,
},
})
return { sessionToken: token, expires: session.expires }
}
public async validateSessionToken(token: string): Promise<SessionResult> {
const sessionToken = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
)
const result = await this.db.session.findUnique({
where: { sessionToken },
include: { user: true },
})
if (!result) return { expires: new Date() }
const { user, ...session } = result
if (Date.now() > session.expires.getTime()) {
await this.db.session.delete({ where: { sessionToken } })
return { expires: new Date() }
}
if (Date.now() >= session.expires.getTime() - this.EXPIRATION_TIME / 2) {
session.expires = new Date(Date.now() + this.EXPIRATION_TIME)
await this.db.session.update({
where: { sessionToken },
data: { expires: session.expires },
})
}
return { user, expires: session.expires }
}
public async invalidateSessionToken(token: string): Promise<void> {
const sessionToken = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
)
await this.db.session.delete({ where: { sessionToken } })
}
public async invalidateAllSessionTokens(userId: User['id']): Promise<void> {
await this.db.session.deleteMany({ where: { userId } })
}
}
export interface SessionResult {
user?: User
expires: Date
}
- First, we create a class called
Session
that takes thedb
instance as an argument and sets the expiration time for the session. - Second, we create a function called
generateSessionToken
that generates a random session token. - Third, we create a function called
createSession
that creates a new session for the user. - Fourth, we create a function called
validateSessionToken
that validates the session token and returns the user and expiration date. - Fifth, we create a function called
invalidateSessionToken
that invalidates the session token. - Finally, we create a function called
invalidateAllSessionTokens
that invalidates all session tokens for the user.
Create password class
Next, let's create a class called Password
that handles password hashing. Create a new file called server/auth/password.ts
and add the following code:
Remember to set the AUTH_SECRET
environment variable. You can generate a random secret using the following command:
openssl rand -hex 32
import { sha3_256 } from '@oslojs/crypto/sha3'
import { encodeBase32LowerCase } from '@oslojs/encoding'
export class Password {
public hash(password: string): string {
const saltedPassword = `${password}${process.env.AUTH_SECRET}`
return encodeBase32LowerCase(
sha3_256(new TextEncoder().encode(saltedPassword)),
)
}
public verify(password: string, hash: string): boolean {
const saltedPassword = `${password}${process.env.AUTH_SECRET}`
const hashPassword = encodeBase32LowerCase(
sha3_256(new TextEncoder().encode(saltedPassword)),
)
return hashPassword === hash
}
}
Setting up the OAuth API
First, let's create a new file called server/auth/auth.ts
and add the following code:
import type { User } from '@prisma/client'
import type { OAuth2Tokens } from 'arctic'
import type { NextRequest } from 'next/server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextResponse } from 'next/server'
import { generateCodeVerifier, generateState, OAuth2RequestError } from 'arctic'
import type { SessionResult } from './session'
import { db } from '@/server/db'
import { Password } from './password'
import { Session } from './session'
export interface AuthOptions {
cookieKey: string
providers: Providers
}
class AuthClass {
private readonly db: typeof db
private readonly session: Session
private readonly password: Password
private readonly COOKIE_KEY: string
private readonly providers: Providers
constructor(options: AuthOptions) {
this.COOKIE_KEY = options.cookieKey
this.providers = options.providers
this.db = db
this.session = new Session()
this.password = new Password()
}
public async auth(req?: NextRequest): Promise<SessionResult> {
let authToken: string | undefined
if (req)
authToken =
req.cookies.get(this.COOKIE_KEY)?.value ??
req.headers.get('Authorization')?.replace('Bearer ', '')
else authToken = (await cookies()).get(this.COOKIE_KEY)?.value
if (!authToken) return { expires: new Date() }
return await this.session.validateSessionToken(authToken)
}
public async handlers(req: NextRequest): Promise<Response> {
const url = new URL(req.nextUrl)
let response: NextResponse = NextResponse.json(
{ error: 'Not found' },
{ status: 404 },
)
switch (req.method) {
case 'OPTIONS':
response = NextResponse.json('', { status: 204 })
break
case 'GET':
if (url.pathname === '/api/auth') {
const session = await this.auth(req)
response = NextResponse.json(session)
} else if (url.pathname.startsWith('/api/auth/oauth')) {
const isCallback = url.pathname.endsWith('/callback')
if (!isCallback) {
const provider =
this.providers[String(url.pathname.split('/').pop())]
if (!provider) {
response = NextResponse.json(
{ error: 'Provider not supported' },
{ status: 404 },
)
break
}
const state = generateState()
const codeVerifier = generateCodeVerifier()
const authorizationUrl = provider.createAuthorizationURL(
state,
codeVerifier,
)
response = NextResponse.redirect(
new URL(authorizationUrl, req.nextUrl),
)
response.cookies.set('code_verifier', codeVerifier)
response.cookies.set('oauth_state', state)
} else {
const provider =
this.providers[String(url.pathname.split('/').slice(-2)[0])]
if (!provider) {
response = NextResponse.json(
{ error: 'Provider not supported' },
{ status: 404 },
)
break
}
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const storedState = req.cookies.get('oauth_state')?.value ?? ''
const codeVerifier = req.cookies.get('code_verifier')?.value ?? ''
try {
if (!code || !state || state !== storedState)
throw new Error('Invalid state')
const { validateAuthorizationCode, fetchUserUrl, mapUser } =
provider
const verifiedCode = await validateAuthorizationCode(
code,
codeVerifier,
)
const token = verifiedCode.accessToken()
const res = await fetch(fetchUserUrl, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch user data')
const user = await this.createUser(
mapUser((await res.json()) as never),
)
const session = await this.session.createSession(user.id)
response = NextResponse.redirect(new URL('/', req.nextUrl))
response.cookies.set(this.COOKIE_KEY, session.sessionToken, {
httpOnly: true,
path: '/',
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: session.expires,
})
response.cookies.delete('oauth_state')
response.cookies.delete('code_verifier')
} catch (error) {
if (error instanceof OAuth2RequestError) {
response = NextResponse.json(
{ error: error.message, description: error.description },
{ status: 400 },
)
} else if (error instanceof Error)
response = NextResponse.json(
{ error: error.message },
{ status: 400 },
)
else
response = NextResponse.json(
{ error: 'An unknown error occurred' },
{ status: 400 },
)
}
}
}
break
case 'POST':
if (url.pathname === '/api/auth/sign-out') {
await this.signOut(req)
response = NextResponse.redirect(new URL('/', req.url))
response.cookies.delete(this.COOKIE_KEY)
}
break
}
this.setCorsHeaders(response)
return response
}
public async signIn(
type: SignInType,
values?: { email: string; password: string },
): Promise<void> {
if (type === 'credentials' && values) {
const { email, password } = values
const user = await this.db.user.findUnique({ where: { email } })
if (!user) throw new Error('User not found')
if (!user.password) throw new Error('User has no password')
const passwordMatch = this.password.verify(password, user.password)
if (!passwordMatch) throw new Error('Invalid password')
const session = await this.session.createSession(user.id)
;(await cookies()).set('auth_token', session.sessionToken, {
httpOnly: true,
path: '/',
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: session.expires,
})
} else {
redirect(`/api/auth/oauth/${type}`)
}
}
public async signOut(req?: NextRequest): Promise<void> {
const token = await this.getToken(req)
await this.session.invalidateSessionToken(token)
}
private async getToken(req?: NextRequest): Promise<string> {
if (req)
return (
req.cookies.get(this.COOKIE_KEY)?.value ??
req.headers.get('Authorization')?.replace('Bearer ', '') ??
''
)
return (await cookies()).get(this.COOKIE_KEY)?.value ?? ''
}
private async createUser(data: {
provider: string
providerAccountId: string
providerAccountName: string
email: string
image: string
}): Promise<User> {
const { provider, providerAccountId, providerAccountName, email, image } =
data
const existingAccount = await db.account.findUnique({
where: { provider_providerAccountId: { provider, providerAccountId } },
})
if (existingAccount) {
const user = await db.user.findUnique({
where: { id: existingAccount.userId },
})
if (!user) throw new Error(`Failed to sign in with ${provider}`)
return user
}
const accountData = {
provider,
providerAccountId,
providerAccountName,
}
return await db.user.upsert({
where: { email },
update: { accounts: { create: accountData } },
create: {
email,
name: providerAccountName,
image,
accounts: { create: accountData },
},
})
}
private setCorsHeaders(res: Response): void {
res.headers.set('Access-Control-Allow-Origin', '*')
res.headers.set('Access-Control-Request-Method', '*')
res.headers.set('Access-Control-Allow-Methods', 'OPTIONS, GET, POST')
res.headers.set('Access-Control-Allow-Headers', '*')
}
}
export const Auth = (options: AuthOptions) => {
const authInstance = new AuthClass(options)
return {
auth: (req?: NextRequest) => authInstance.auth(req),
signIn: (type: SignInType, values?: typeof signInSchema.infer) =>
authInstance.signIn(type, values),
signOut: (req?: NextRequest) => authInstance.signOut(req),
handlers: (req: NextRequest) => authInstance.handlers(req),
}
}
type SignInType = 'credentials' | 'google' // Add more providers as needed for type safety
type Providers = Record<
string,
{
createAuthorizationURL: (state: string, codeVerifier: string) => URL
validateAuthorizationCode: (
code: string,
codeVerifier: string,
) => Promise<OAuth2Tokens>
fetchUserUrl: string
mapUser: (user: never) => {
provider: string
providerAccountId: string
providerAccountName: string
email: string
image: string
}
}
>
- First, we create an interface called
AuthOptions
that takes thecookieKey
andproviders
as arguments. - Second, we create a class called
AuthClass
that takes theoptions
as an argument and sets thecookieKey
andproviders
. - Third, we create a function called
auth
that validates the session token and returns the user and expiration date. - Fourth, we create a function called
handlers
that handles the different HTTP methods and routes. - Fifth, we create a function called
signIn
that signs in the user using the credentials or OAuth provider. - Sixth, we create a function called
signOut
that signs out the user. - Finally, we create a function called
createUser
that creates a new user or updates an existing user.
Setting up the OAuth providers
First, let's create a new file called server/auth/config.ts
and add the following code:
const getBaseUrl = () => {
if (process.env.VERCEL_PROJECT_PRODUCTION_URL)
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return `http://localhost:${process.env.PORT ?? 3000}`
}
Remember to set the GOOGLE_CLIENT_ID
and GOOGLE_CLIENT_SECRET
environment
variables.
'use server'
import { Google } from 'arctic'
import type { AuthOptions } from './utils/auth'
import { getBaseUrl } from '@/lib/utils'
import { Auth } from './auth'
const google = new Google(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
`${getBaseUrl()}/api/auth/oauth/google/callback`,
)
const authOptions = {
cookieKey: 'auth_token',
providers: {
google: {
createAuthorizationURL: (state, codeVerifier) =>
google.createAuthorizationURL(state, codeVerifier, [
'openid',
'profile',
'email',
]),
validateAuthorizationCode: (code, codeVerifier) =>
google.validateAuthorizationCode(code, codeVerifier),
fetchUserUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
mapUser: (user: {
sub: string
email: string
name: string
picture: string
}) => ({
provider: 'google',
providerAccountId: user.sub,
providerAccountName: user.name,
email: user.email,
image: user.picture,
}),
},
},
} satisfies AuthOptions
export const { auth, signIn, signOut, handlers } = Auth(authOptions)
import { cache } from 'react'
import { handlers, signIn, signOut, auth as uncachedAuth } from './configs'
const auth = cache(uncachedAuth)
export { auth, signIn, signOut, handlers }
- First, we create a function called
getBaseUrl
that returns the base URL of the application. - Second, we create a new file called
server/auth/config.ts
that sets up the Google OAuth provider or any other provider as needed. - Third, we create a function called
mapUser
that maps the user data from Google to our user schema. - Finally, we create a new file called
server/auth/index.ts
that caches theauth
function.
Using the authentication system
Setting up the API routes
First, let's create a new file called pages/api/auth.ts
and add the following code:
import { handlers } from '@/server/auth'
export { handlers as GET, handlers as POST, handlers as OPTIONS }
Setting up react hooks for authentication
'use client'
import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import type { SessionResult } from './utils/session'
interface SessionContextValue {
session: SessionResult
isLoading: boolean
}
const SessionContext = React.createContext<SessionContextValue | undefined>(
undefined,
)
export const useSession = () => {
const ctx = React.useContext(SessionContext)
if (!ctx) throw new Error('useSession must be used within a SessionProvider')
return ctx
}
export const SessionProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { data: session = { expires: new Date() }, isLoading } = useQuery({
queryKey: ['auth'],
queryFn: async () => {
const res = await fetch('/api/auth')
return res.json() as Promise<SessionResult>
},
})
return (
<SessionContext.Provider value={{ session, isLoading }}>
{children}
</SessionContext.Provider>
)
}
- First, we create a new file called
hooks/use-session.ts
that creates aSessionContext
and auseSession
hook. - Second, we create a
SessionProvider
component that fetches the session data from the API. - Finally, we export the
useSession
hook and theSessionProvider
component.
Adding the authentication provider to the app
import { SessionProvider } from '@/hooks/use-session'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
{children}
</SessionProvider>
</QueryClientProvider>
)
)
- First, we import the
SessionProvider
component and theQueryClientProvider
component. - Second, we create a new
QueryClient
instance. - Finally, we wrap the
SessionProvider
component with theQueryClientProvider
component.
Using the authentication provider in the app
You can now use the useSession
hook in your components to access the session data:
import { useSession } from '@/hooks/use-session'
export default function Page() {
const { session, isLoading } = useSession()
if (isLoading) return <div>Loading...</div>
if (!session.user) return <div>Not authenticated</div>
return <div>Welcome, {session.user.name}</div>
}
Conclusion
In this guide, we've walked through how to build an authentication system from scratch using Arctic for OAuth. By following these steps, you can create a secure and reliable authentication system for your application. If you have any questions or need further assistance, feel free to reach out to the Arctic community for support.
Resources
Arch Linux with Hyprland: A Beginner's Guide
Discover the power of Arch Linux and Hyprland, two popular choices for minimalist and highly customizable desktop setups. This tutorial will teach you how to install and configure both, providing you with a tailored desktop experience.
Build your own form
Learn how to build your own form with React and usng Standard Schema to validate the form.