How to build an authentication system from scratch
Thu Jan 16 2025
Learn how to build an authentication system from scratch using Arctic for OAuth.
AuthOAuthArctic
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:
bun add @oslojs/crypto @oslojs/encoding arctic
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:
prisma/schema.prisma
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 providerId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String @@id([provider, providerId])}model Session { sessionToken String @unique expiresAt DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String createdAt DateTime @default(now())}
In this function, we check if the session token exists in the database and if it has expired. If the token is valid, we return the user and the expiration date. If the token has expired, we delete it from the database and return the current date.
Next, let's set up the OAuth API. Create a new file called server/auth/oauth.ts and add the following code:
First, let's create a class called OAuth that takes the provider, client ID, client secret, and callback URL as arguments:
Note: in this example, we use Discord as the provider. So that, remember to add DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET to your environment variables.
Note 2: You can add more providers base on Arctic documentaion.
Second, let's create a function called getOAuthURL that generates the OAuth URL for the provider:
server/auth/oauth.ts
class OAuth { // ... public getOAuthUrl(): { url: URL; state: string } { const state = generateState() const url = this.provider.createAuthorizationURL.length === 3 ? // @ts-expect-error - This is a hack to make the types work this.provider.createAuthorizationURL(state, null, this.scopes) : // @ts-expect-error - This is a hack to make the types work this.provider.createAuthorizationURL(state, this.scopes) return { url, state } }}
Third, let's create a function called callback that handles the OAuth callback:
server/auth/oauth.ts
import { db } from '@/server/db'class OAuth { // ... public async callback(code: string) { const tokens = this.provider.validateAuthorizationCode.length == 2 ? await this.provider.validateAuthorizationCode(code, '') : // @ts-expect-error - This is a hack to make the types work await this.provider.validateAuthorizationCode(code) switch (this.name) { case 'discord': await this.discord(tokens.accessToken()) break } return await this.createUser() }}
Fourth, let's create a function called discord that fetches the user's information from Discord:
server/auth/oauth.ts
class OAuth { // ... private async discord(token: string) { // prettier-ignore interface DiscordUser { id: string; email: string; username: string; avatar: string } this.oauthUser = await fetch('https://discord.com/api/users/@me', { headers: { Authorization: `Bearer ${token}` }, }) .then((res) => res.json() as Promise<DiscordUser>) .then((account) => ({ id: account.id, name: account.username, email: account.email, image: `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.png`, })) .catch(() => { throw new Error('Failed to fetch user data from Discord') }) }}
Finally, let's create a function called createUser that creates a new user in the database:
server/auth/oauth.ts
class OAuth { // ... private async createUser() { const { id, email, name, image } = this.oauthUser const create = { provider: this.name, providerId: id } const account = await db.account.findUnique({ where: { provider_providerId: { provider: this.name, providerId: id } }, }) let user = await db.user.findFirst({ where: { email } }) if (!account && !user) user = await db.user.create({ data: { email, name, image, accounts: { create } }, }) else if (!account && user) user = await db.user.update({ where: { email }, data: { accounts: { create } }, }) if (!user) throw new Error(`Failed to sign in with ${this.name}`) return user }}
Consuming the OAuth API
server/auth/oauth.ts
import { authEnv } from '@yuki/auth/env'import { db } from '@yuki/db'import { Discord, generateState, GitHub } from 'arctic'export class OAuth { private name: string private provider: Discord | GitHub private scopes: string[] private oauthUser: { id: string; email: string; name: string; image: string } constructor(provider: string, callback_url: string) { this.oauthUser = { id: '', email: '', name: '', image: '' } switch (provider) { case 'discord': this.name = 'discord' this.provider = new Discord( env.DISCORD_ID, env.DISCORD_SECRET, callback_url, ) this.scopes = ['identify', 'email'] break default: throw new Error(`Provider ${provider} not supported`) } } public getOAuthUrl(): { url: URL; state: string } { const state = generateState() const url = this.provider.createAuthorizationURL.length === 3 ? // @ts-expect-error - This is a hack to make the types work this.provider.createAuthorizationURL(state, null, this.scopes) : // @ts-expect-error - This is a hack to make the types work this.provider.createAuthorizationURL(state, this.scopes) return { url, state } } public async callback(code: string) { const tokens = this.provider.validateAuthorizationCode.length == 2 ? await this.provider.validateAuthorizationCode(code, '') : // @ts-expect-error - This is a hack to make the types work await this.provider.validateAuthorizationCode(code) switch (this.name) { case 'discord': await this.discord(tokens.accessToken()) break } return await this.createUser() } private async createUser() { const { id, email, name, image } = this.oauthUser const create = { provider: this.name, providerId: id } const account = await db.account.findUnique({ where: { provider_providerId: { provider: this.name, providerId: id } }, }) let user = await db.user.findFirst({ where: { email } }) if (!account && !user) user = await db.user.create({ data: { email, name, image, accounts: { create } }, }) else if (!account && user) user = await db.user.update({ where: { email }, data: { accounts: { create } }, }) if (!user) throw new Error(`Failed to sign in with ${this.name}`) return user } private async discord(token: string) { // prettier-ignore interface DiscordUser { id: string; email: string; username: string; avatar: string } this.oauthUser = await fetch('https://discord.com/api/users/@me', { headers: { Authorization: `Bearer ${token}` }, }) .then((res) => res.json() as Promise<DiscordUser>) .then((account) => ({ id: account.id, name: account.username, email: account.email, image: `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.png`, })) .catch(() => { throw new Error('Failed to fetch user data from Discord') }) }}
Setting up the API routes
We use Next.js in this example.
Frist, cretae some helper functions in lib/auth/server.ts:
Next, let's set up the API routes. Create a new file called app/api/auth/[...auth]/route.ts and add the following code:
app/api/auth/[...auth]/route.ts
import type { NextRequest } from 'next/server'import { cookies } from 'next/headers'import { NextResponse } from 'next/server'import { OAuth2RequestError } from 'arctic'import { signIn } from '@/lib/auth/server'import { OAuth } from '@/server/auth/oauth'export const GET = async ( req: NextRequest, { params }: { params: Promise<{ auth: [string, string] }> },) => { const nextUrl = new URL(req.url) const [provider, isCallback] = (await params).auth const callbackUrl = `${nextUrl.origin}/api/auth/${provider}/callback` const authProvider = new OAuth(provider, callbackUrl) if (!isCallback) { const { url, state } = authProvider.getOAuthUrl() ;(await cookies()).set('oauth_state', `${state}`) return NextResponse.redirect(new URL(`${url}`, nextUrl)) } try { const code = nextUrl.searchParams.get('code') ?? '' const state = nextUrl.searchParams.get('state') ?? '' const storedState = req.cookies.get('oauth_state')?.value ?? '' ;(await cookies()).delete('oauth_state') if (!code || !state || state !== storedState) throw new Error('Invalid state') const user = await authProvider.callback(code) await signIn(user.id) return NextResponse.redirect(new URL('/', nextUrl)) } catch (e) { if (e instanceof OAuth2RequestError) return NextResponse.json({ error: e.message }, { status: Number(e.code) }) else if (e instanceof Error) return NextResponse.json({ error: e.message }, { status: 500 }) else return NextResponse.json( { error: 'An unknown error occurred' }, { status: 500 }, ) }}
If you want to get session data in the client side, you can create a hook in lib/auth/react.tsx
lib/auth/react.tsx
'use client'import { createContext, use } from 'react'import type { SessionValidation } from '@/server/auth'const sessionContext = createContext<SessionValidation | undefined>(undefined)export const SessionProvider: React.FC< Readonly<{ session: SessionValidation children: React.ReactNode }>> = ({ session, children }) => ( <sessionContext.Provider value={session}>{children}</sessionContext.Provider>)export const useSession = () => { const context = use(sessionContext) if (!context) throw new Error('useSession must be used within a SessionProvider') return context}
Then wrap your app with SessionProvider in app/layout.tsx
app/layout.tsx
import { SessionProvider } from '@/lib/auth/react'import { auth } from '@/lib/auth/server'const RootLayout: React.FC<React.PropsWithChildren> = ({ children }) => { const session = auth() return <SessionProvider session={session}>{children}</SessionProvider>}
Now, you can use useSession hook in your components to get session data.
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.