Authentication

Eventuall uses NextAuth (Auth.js v5 beta) for authentication with a custom D1/SQLite adapter. Users can sign in with Google OAuth or via OTP (one-time password) sent through Twilio.

How Authentication Works

When a user visits a protected page, the NextAuth middleware checks for a valid session cookie. If no session exists, the user is redirected to /login. After successful authentication, a session is created in the D1 database and a cookie (eventuall_session) is set in the browser.

sequenceDiagram
    participant Browser
    participant Middleware
    participant NextAuth
    participant D1 as D1 Database
    participant Provider as Auth Provider

    Browser->>Middleware: Request /event/123
    Middleware->>Middleware: Check session cookie
    alt No session
        Middleware-->>Browser: Redirect to /login
        Browser->>NextAuth: Sign in with Google/OTP
        NextAuth->>Provider: Validate credentials
        Provider-->>NextAuth: User identity
        NextAuth->>D1: Create/update user + session
        D1-->>NextAuth: Session token
        NextAuth-->>Browser: Set cookie + redirect
    else Valid session
        Middleware-->>Browser: Allow request
    end

Session Strategy

Sessions use the "database" strategy, not JWT. This means the session token in the cookie is an opaque reference — the actual session data lives in the D1 database. Sessions expire after 180 days.

Architect's Note: Database sessions allow server-side revocation (you can invalidate a session by deleting it from the database), but they add a database query on every authenticated request. JWT sessions would be faster but can't be revoked server-side. For a platform handling live events where you might need to immediately revoke a compromised session, database sessions are the safer choice.

Authentication Providers

Google OAuth

Google OAuth is the primary authentication method. The flow:

  1. User clicks "Sign in with Google"
  2. NextAuth redirects to Google's consent screen
  3. Google redirects back with an authorization code
  4. NextAuth exchanges the code for user information
  5. The system creates or links the user account

Configuration is in apps/webapp/src/server/auth/config.ts. Google OAuth requires AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET environment variables.

OTP (One-Time Password)

For users without Google accounts, Eventuall supports OTP authentication via Twilio Verify:

  1. User enters their phone number or email
  2. The system sends a verification code via Twilio
  3. User enters the code
  4. The system validates it against Twilio's Verify API

The OTP provider is a custom NextAuth Credentials provider defined in apps/webapp/src/server/auth/providers/OTP.ts. It handles three error states:

  • OTPTokenExpired — the code has timed out
  • OTPTokenInvalid — wrong code
  • OTPMaxAttemptsReached — too many failed attempts

Pitfall: The Credentials provider in NextAuth doesn't support the standard session creation flow. Eventuall has a custom createCredentialSession() function that manually creates a session record in D1 and sets the cookie. This is a workaround for a NextAuth limitation with database session strategy + credentials providers.

User Creation Flow

When a user signs in for the first time, the signIn event callback handles account creation:

  1. Look up the user by email in the database
  2. If the user doesn't exist, create them with the provider's profile data
  3. Create a "personal account" for the user (every user gets one)
  4. Link the OAuth provider to the user record in userProviders

If the user already exists but signed in with a different provider (e.g., they first used Google, now using OTP), the system links the new provider to the existing account.

Custom D1 Adapter

NextAuth's built-in Drizzle adapter doesn't work with Cloudflare D1 because D1 requires a fresh database connection per request. The custom adapter in apps/webapp/src/server/auth/adapter/ wraps the standard Drizzle adapter to accept a function-based database client instead of a static instance:

// The adapter gets the DB connection lazily, per-request
export function DrizzleD1Adapter(clientFn: () => D1Database) {
  // Each adapter method calls clientFn() to get a fresh D1 connection
}

This prevents the stale connection problem that would occur with a cached database client on Cloudflare.

Middleware

Route protection is handled in apps/webapp/src/middleware.ts. The middleware matches specific route patterns and checks authentication:

Protected routes: /event/* and /developer/* — redirects unauthenticated users to /login

Login redirect: Authenticated users visiting /login are redirected to / (prevents the login page from showing when already signed in)

Composite route exception: Routes with a special composite header bypass authentication. This allows LiveKit's egress service (which captures video) to access event pages without a user session.

Pitfall: The composite route exception is security-sensitive. It's gated on a specific header value, not IP whitelisting. If someone discovers the header value, they could access any event page without authentication. Consider adding IP-based restrictions for the egress service in production.

How tRPC Uses Authentication

The tRPC context creation (apps/webapp/src/server/api/trpc.ts) calls auth() to get the current session. This session is available to all tRPC procedures.

Public procedures (publicProcedure) don't require authentication — ctx.session may be null.

Protected procedures (protectedProcedure) add middleware that throws UNAUTHORIZED if no session exists. After this middleware, TypeScript narrows ctx.session to be non-null, so you get compile-time guarantees:

// In a protected procedure, ctx.session.user is guaranteed to exist
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: { session: { ...ctx.session, user: ctx.session.user } },
  });
});

Server Actions and Authentication

Server Actions use a separate authentication chain through next-safe-action. The authenticatedActionClient middleware validates the session and provides the user context:

export const authenticatedActionClient = publicActionClient
  .use(async ({ next }) => {
    const session = await auth();
    if (!session?.user) {
      throw new ActionError("You must be logged in", "UNAUTHORIZED");
    }
    return next({ ctx: { user: session.user, db: getDB() } });
  });

This means both tRPC procedures and Server Actions have authenticated access to the database and user context, but through different code paths.

Role-Based Access Control (RBAC)

Beyond authentication, Eventuall has a permission system built on roles and access grants:

  • Permissions are granular capabilities (e.g., view:stream, join:stage, manage:event)
  • Roles bundle permissions together (e.g., "moderator" has manage:event + join:stage)
  • Roles can inherit from other roles through the rolesInheritances table
  • Access grants assign a user a role for a specific event + room combination

To check if a user can perform an action, the system looks up their access grants for the event/room, resolves the role (including inherited roles), and checks if the required permission exists.

Architect's Note: The RBAC system uses a SQL view (rolePermissionsView) that pre-computes role-to-permission mappings including inheritance. This avoids recursive queries at runtime but means the view must be refreshed when role hierarchies change. Currently, role hierarchies are static (defined at seed time), but if you make them dynamic, consider the query cost implications.