Skip to main content

Overview

The DeltaHacks Portal uses NextAuth.js v4 for authentication, providing secure OAuth-based login with multiple providers. The system integrates with Prisma for session storage and implements role-based access control (RBAC).

OAuth Providers

The portal supports five OAuth providers:

Discord

Primary provider for DeltaHacks community members.

Google

Common provider for students and professionals.

GitHub

Popular among developers and hackers.

LinkedIn

Professional networking platform.

Azure AD

Enterprise authentication for organizations.

NextAuth Configuration

The authentication is configured in src/pages/api/auth/[...nextauth].ts:
import NextAuth, { type NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import GoogleProvider from "next-auth/providers/google";
import GithubProvider from "next-auth/providers/github";
import LinkedInProvider from "next-auth/providers/linkedin";
import AzureADProvider from "next-auth/providers/azure-ad";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "../../../server/db/client";
import { env } from "../../../env/server.mjs";

export const authOptions: NextAuthOptions = {
  callbacks: {
    session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        session.user.role = user.role;
      }
      return session;
    },
  },
  pages: {
    signIn: "/login",
    signOut: "/login",
    error: "/login",
  },
  adapter: PrismaAdapter(prisma),
  providers: [
    DiscordProvider({
      clientId: env.DISCORD_CLIENT_ID,
      clientSecret: env.DISCORD_CLIENT_SECRET,
    }),
    GoogleProvider({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    GithubProvider({
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    }),
    LinkedInProvider({
      clientId: env.LINKEDIN_CLIENT_ID,
      clientSecret: env.LINKEDIN_CLIENT_SECRET,
    }),
    AzureADProvider({
      clientId: env.AZURE_AD_CLIENT_ID,
      clientSecret: env.AZURE_AD_CLIENT_SECRET,
      tenantId: env.AZURE_AD_TENANT_ID,
    }),
  ],
};

export default NextAuth(authOptions);

Environment Variables

Required environment variables in .env:
# NextAuth
NEXTAUTH_SECRET="your-secret-here"
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_URL=http://localhost:3000

# Discord
DISCORD_CLIENT_ID="..."
DISCORD_CLIENT_SECRET="..."

# Google
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."

# GitHub
GITHUB_CLIENT_ID="..."
GITHUB_CLIENT_SECRET="..."

# LinkedIn
LINKEDIN_CLIENT_ID="..."
LINKEDIN_CLIENT_SECRET="..."

# Azure AD
AZURE_AD_CLIENT_ID="..."
AZURE_AD_TENANT_ID="..."
AZURE_AD_CLIENT_SECRET="..."
Contact the Technical VPs to receive the required environment variables for development.

Authentication Flow

Session Management

Database Sessions

NextAuth uses the Prisma adapter to store sessions in the database:
model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Session Callback

The session callback enriches the session with user ID and roles:
callbacks: {
  session({ session, user }) {
    if (session.user) {
      session.user.id = user.id;
      session.user.role = user.role;  // Array of roles from database
    }
    return session;
  },
}

TypeScript Types

Extend NextAuth types in src/types/next-auth.d.ts:
import { Role } from "@prisma/client";
import { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user?: {
      id: string;
      role: Role[];
    } & DefaultSession["user"];
  }
}

Server-Side Session Access

In API Routes

import { getServerSession } from "next-auth";
import { authOptions } from "./auth/[...nextauth]";

export default async function handler(req, res) {
  const session = await getServerSession(req, res, authOptions);
  
  if (!session) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  
  // Access user data
  const userId = session.user.id;
  const roles = session.user.role;
  
  res.json({ user: session.user });
}

In getServerSideProps

import { getServerAuthSession } from "../server/common/get-server-auth-session";

export async function getServerSideProps(context) {
  const session = await getServerAuthSession(context);
  
  if (!session) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }
  
  return {
    props: { session },
  };
}

Helper Function

import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "../../pages/api/auth/[...nextauth]";

export const getServerAuthSession = async (ctx: {
  req: GetServerSidePropsContext["req"];
  res: GetServerSidePropsContext["res"];
}) => {
  return await getServerSession(ctx.req, ctx.res, authOptions);
};

Client-Side Session Access

useSession Hook

import { useSession } from "next-auth/react";

function ProfilePage() {
  const { data: session, status } = useSession();
  
  if (status === "loading") {
    return <div>Loading...</div>;
  }
  
  if (status === "unauthenticated") {
    return <div>Please sign in</div>;
  }
  
  return (
    <div>
      <p>Signed in as {session.user.email}</p>
      <p>Roles: {session.user.role.join(", ")}</p>
    </div>
  );
}

Sign In/Out

import { signIn, signOut } from "next-auth/react";

function LoginButton() {
  return (
    <button onClick={() => signIn("discord")}>Sign in with Discord</button>
  );
}

function LogoutButton() {
  return (
    <button onClick={() => signOut()}>Sign out</button>
  );
}

Protected Client Components

import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect } from "react";

function ProtectedPage() {
  const { data: session, status } = useSession();
  const router = useRouter();
  
  useEffect(() => {
    if (status === "unauthenticated") {
      router.push("/login");
    }
  }, [status, router]);
  
  if (status === "loading") {
    return <div>Loading...</div>;
  }
  
  return <div>Protected content</div>;
}

Role-Based Access Control

Role Enum

Roles are defined in the Prisma schema:
enum Role {
  HACKER          // Regular participants
  ADMIN           // Full administrative access
  REVIEWER        // Application review access
  FOOD_MANAGER    // Meal tracking and management
  EVENT_MANAGER   // Event check-in management
  GENERAL_SCANNER // General QR scanning access
  SPONSER         // Sponsor portal access
  JUDGE           // Project judging access
}

User Roles

Users can have multiple roles (array):
model User {
  role Role[] @default([HACKER])
}

Server-Side Role Checks

In tRPC Procedures

import { TRPCError } from "@trpc/server";
import { Role } from "@prisma/client";

export const adminRouter = router({
  deleteUser: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // Check if user has ADMIN role
      if (!ctx.session.user.role.includes(Role.ADMIN)) {
        throw new TRPCError({ code: "UNAUTHORIZED" });
      }
      
      // Admin-only operation
      await ctx.prisma.user.delete({ where: { id: input.id } });
    }),
});

Multiple Role Check

export const applicationRouter = router({
  getAll: protectedProcedure.query(async ({ ctx }) => {
    const userRoles = ctx.session.user.role;
    
    // Allow ADMIN or REVIEWER
    if (!userRoles.includes(Role.ADMIN) && !userRoles.includes(Role.REVIEWER)) {
      throw new TRPCError({ code: "UNAUTHORIZED" });
    }
    
    return await ctx.prisma.dH12Application.findMany();
  }),
});

Role-Based Middleware

import { middleware } from "./trpc";

const isAdmin = middleware(({ ctx, next }) => {
  if (!ctx.session?.user?.role.includes(Role.ADMIN)) {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  return next();
});

const adminProcedure = protectedProcedure.use(isAdmin);

export const adminRouter = router({
  deleteUser: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // User is guaranteed to be admin
    }),
});

Client-Side Role Checks

import { useSession } from "next-auth/react";
import { Role } from "@prisma/client";

function AdminPanel() {
  const { data: session } = useSession();
  
  if (!session?.user?.role.includes(Role.ADMIN)) {
    return <div>Access denied</div>;
  }
  
  return <div>Admin content</div>;
}

Conditional Rendering

function Navigation() {
  const { data: session } = useSession();
  const roles = session?.user?.role || [];
  
  return (
    <nav>
      <a href="/dashboard">Dashboard</a>
      
      {roles.includes(Role.ADMIN) && (
        <a href="/admin">Admin Panel</a>
      )}
      
      {roles.includes(Role.REVIEWER) && (
        <a href="/reviews">Review Applications</a>
      )}
      
      {roles.includes(Role.JUDGE) && (
        <a href="/judging">Judge Projects</a>
      )}
      
      {(roles.includes(Role.FOOD_MANAGER) || roles.includes(Role.GENERAL_SCANNER)) && (
        <a href="/scanner">QR Scanner</a>
      )}
    </nav>
  );
}

Protected Routes

Page-Level Protection

import { getServerAuthSession } from "../server/common/get-server-auth-session";
import { Role } from "@prisma/client";

export async function getServerSideProps(context) {
  const session = await getServerAuthSession(context);
  
  // Redirect if not authenticated
  if (!session) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }
  
  // Redirect if not admin
  if (!session.user.role.includes(Role.ADMIN)) {
    return {
      redirect: {
        destination: "/dashboard",
        permanent: false,
      },
    };
  }
  
  return {
    props: { session },
  };
}

function AdminPage({ session }) {
  return <div>Admin Dashboard</div>;
}

export default AdminPage;

Middleware (Experimental)

Create middleware.ts at the root for route protection:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });
  
  // Protect /admin routes
  if (request.nextUrl.pathname.startsWith("/admin")) {
    if (!token?.role?.includes("ADMIN")) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ["/admin/:path*", "/scanner/:path*"],
};

Managing User Roles

Assigning Roles

// Admin procedure to assign roles
export const adminRouter = router({
  assignRole: adminProcedure
    .input(z.object({
      userId: z.string(),
      role: z.nativeEnum(Role),
    }))
    .mutation(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.userId },
      });
      
      if (!user) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
      
      // Add role if not already present
      if (!user.role.includes(input.role)) {
        await ctx.prisma.user.update({
          where: { id: input.userId },
          data: {
            role: {
              push: input.role,
            },
          },
        });
      }
    }),
});

Removing Roles

removeRole: adminProcedure
  .input(z.object({
    userId: z.string(),
    role: z.nativeEnum(Role),
  }))
  .mutation(async ({ ctx, input }) => {
    const user = await ctx.prisma.user.findUnique({
      where: { id: input.userId },
    });
    
    if (!user) {
      throw new TRPCError({ code: "NOT_FOUND" });
    }
    
    // Remove role
    await ctx.prisma.user.update({
      where: { id: input.userId },
      data: {
        role: user.role.filter(r => r !== input.role),
      },
    });
  }),

Security Best Practices

Use HTTPS in Production

Always use HTTPS for OAuth callbacks and session cookies.

Keep Secrets Secret

Never commit .env files. Use secure secret management.

Validate Sessions Server-Side

Always verify sessions on the server, not just the client.

Implement CSRF Protection

NextAuth automatically handles CSRF tokens.

Check Roles for Every Protected Operation

Never trust client-side role checks alone.

Use Short Session Expiry

Configure appropriate session timeout periods.

Troubleshooting

”NEXTAUTH_URL not set” Error

Ensure NEXTAUTH_URL is set in .env:
NEXTAUTH_URL=http://localhost:3000

OAuth Callback Issues

Verify callback URLs in OAuth provider settings:
http://localhost:3000/api/auth/callback/discord
http://localhost:3000/api/auth/callback/google

Session Not Persisting

Check:
  1. Database connection is active
  2. Session table exists in database
  3. Cookies are enabled in browser
  4. NEXTAUTH_SECRET is set

Role Not Updated

Invalidate and refetch session after role changes:
import { useSession } from "next-auth/react";

const { data: session, update } = useSession();

// Force session refresh
await update();

See Also

Build docs developers (and LLMs) love