Logo

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:

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
  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:

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:

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:

server/auth/session.ts
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 the db 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
server/auth/password.ts
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:

server/auth/auth.ts
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 the cookieKey and providers as arguments.
  • Second, we create a class called AuthClass that takes the options as an argument and sets the cookieKey and providers.
  • 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:

lib/utils.ts
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.

server/auth/config.ts
'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)
server/auth/index.ts
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 the auth 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

hooks/use-session.ts
'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 a SessionContext and a useSession hook.
  • Second, we create a SessionProvider component that fetches the session data from the API.
  • Finally, we export the useSession hook and the SessionProvider component.

Adding the authentication provider to the app

app/layout.tsx
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 the QueryClientProvider component.
  • Second, we create a new QueryClient instance.
  • Finally, we wrap the SessionProvider component with the QueryClientProvider component.

Using the authentication provider in the app

You can now use the useSession hook in your components to access the session data:

app/page.tsx
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