Skip to content

Authentik Authentication Pattern

Standard authentication pattern for all homelab applications using Authentik SSO with conditional auth modes for developer flexibility.

Overview

This pattern enables: - Single Sign-On (SSO) across all applications via Authentik - Offline/local development without Authentik connectivity - Role-based access control (RBAC) via Authentik groups - Supabase RLS integration for data isolation

Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                           AUTH_MODE Environment Variable                     │
├─────────────────────────────────┬───────────────────────────────────────────┤
│              none               │              authentik                     │
├─────────────────────────────────┼───────────────────────────────────────────┤
│ No auth checks                  │ Real OAuth/OIDC flow                      │
│ All routes open                 │ Requires network access to auth.internal  │
│ useSession() returns null       │ Full SSO with groups/RBAC                 │
├─────────────────────────────────┼───────────────────────────────────────────┤
│ Use for:                        │ Use for:                                  │
│ • UI/styling work               │ • Integration testing                     │
│ • Layout prototyping            │ • Pre-deploy verification                 │
│ • Offline development           │ • Staging & Production                    │
└─────────────────────────────────┴───────────────────────────────────────────┘

Why Two Modes (Not Three)?

We intentionally keep this simple:

  1. none - Pure UI development when you can't reach Authentik
  2. authentik - Real auth via VPN/network access to homelab

Why no mock mode? Mock mode adds complexity: - Custom hooks needed to wrap useSession() - Client/server session state divergence - More code to maintain and port to new apps

Instead, developers connect to the homelab via VPN for auth testing. This is simpler and tests real auth flows.

Security Guardrails

These safeguards prevent accidental deployment with weak auth:

1. Build-Time Validation

// next.config.ts
if (process.env.NODE_ENV === 'production' && process.env.AUTH_MODE !== 'authentik') {
  throw new Error('Production builds require AUTH_MODE=authentik')
}

2. Runtime Warning Banner

When AUTH_MODE=none, a visible yellow banner appears:

⚠️ Dev Mode: Authentication disabled (AUTH_MODE=none)

3. Startup Logging

[AUTH] Mode: none
[AUTH] WARNING: Not using real authentication - do not deploy to production

4. CI/CD Enforcement

GitHub Actions should validate:

- name: Validate auth mode
  run: |
    if [[ "$AUTH_MODE" != "authentik" ]]; then
      echo "ERROR: Production deployment requires AUTH_MODE=authentik"
      exit 1
    fi

File Structure

Standard auth file layout for all applications:

src/
├── lib/
│   └── auth/
│       ├── index.ts              # Main exports, mode switching
│       ├── config.ts             # Auth configuration & validation
│       ├── providers/
│       │   └── authentik.ts      # Real Authentik NextAuth config
│       ├── middleware.ts         # Route protection middleware factory
│       ├── rbac.ts               # RBAC helpers (tiers, groups)
│       └── supabase-bridge.ts    # JWT bridge for Supabase RLS
├── components/
│   └── auth/
│       ├── session-provider.tsx  # React context wrapper
│       ├── login-button.tsx      # Conditional login UI
│       ├── user-menu.tsx         # User dropdown with logout
│       └── dev-banner.tsx        # Auth mode warning banner
├── app/
│   ├── login/
│   │   └── page.tsx              # Login page
│   └── api/
│       └── auth/
│           ├── mode/
│           │   └── route.ts      # Returns current auth mode
│           └── [...nextauth]/
│               └── route.ts      # NextAuth API handlers
├── middleware.ts                 # Root middleware (imports from lib/auth)
└── types/
    └── next-auth.d.ts            # NextAuth type extensions

Environment Variables

Required for All Modes

# Auth mode: 'none' | 'authentik'
AUTH_MODE=none

# NextAuth secret (required even for none mode for session signing)
AUTH_SECRET=generate-with-openssl-rand-base64-32

Required for Authentik Mode

AUTH_MODE=authentik

# NextAuth
AUTH_URL=http://localhost:3000           # Or production URL
AUTH_SECRET=your-secret-here
AUTH_TRUST_HOST=true

# Authentik OIDC
AUTHENTIK_ISSUER=https://auth.internal/application/o/{app-name}/
AUTHENTIK_CLIENT_ID=your-client-id
AUTHENTIK_CLIENT_SECRET=your-client-secret

Supabase Integration (All Modes)

NEXT_PUBLIC_SUPABASE_URL=http://10.89.97.214:8000
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_JWT_SECRET=your-jwt-secret      # For signing RLS tokens

Core Implementation

1. Auth Configuration (lib/auth/config.ts)

const VALID_AUTH_MODES = ['none', 'authentik'] as const
export type AuthMode = (typeof VALID_AUTH_MODES)[number]

export function getAuthMode(): AuthMode {
  const mode = process.env.AUTH_MODE || 'authentik'

  if (!VALID_AUTH_MODES.includes(mode as AuthMode)) {
    console.error(`Invalid AUTH_MODE: ${mode}. Defaulting to 'authentik'`)
    return 'authentik'
  }

  // Production safety check
  if (process.env.NODE_ENV === 'production' && mode !== 'authentik') {
    throw new Error('Production requires AUTH_MODE=authentik')
  }

  return mode as AuthMode
}

export function isAuthDisabled(): boolean {
  return getAuthMode() === 'none'
}

2. Main Auth Export (lib/auth/index.ts)

import { getAuthMode, isAuthDisabled } from './config'

// Re-export utilities
export { getAuthMode, isAuthDisabled } from './config'
export * from './rbac'

// Conditional auth based on mode
export async function auth() {
  const mode = getAuthMode()

  if (mode === 'none') {
    return null
  }

  // Authentik mode: use real NextAuth
  const { auth: authentikAuth } = await import('./providers/authentik')
  return authentikAuth()
}

// Export signIn/signOut for authentik mode
export async function signIn(provider?: string, options?: any) {
  const mode = getAuthMode()
  if (mode !== 'authentik') {
    console.warn('[AUTH] signIn called in non-authentik mode')
    return
  }
  const { signIn: nextSignIn } = await import('./providers/authentik')
  return nextSignIn(provider, options)
}

export async function signOut(options?: any) {
  const mode = getAuthMode()
  if (mode !== 'authentik') {
    console.warn('[AUTH] signOut called in non-authentik mode')
    return
  }
  const { signOut: nextSignOut } = await import('./providers/authentik')
  return nextSignOut(options)
}

3. Middleware Factory (lib/auth/middleware.ts)

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getAuthMode } from './config'

export interface MiddlewareConfig {
  publicRoutes?: string[]
  loginPath?: string
}

export function createAuthMiddleware(config: MiddlewareConfig = {}) {
  const { publicRoutes = ['/login', '/api/auth', '/api/health'], loginPath = '/login' } = config

  return async function middleware(request: NextRequest) {
    const mode = getAuthMode()
    const { pathname } = request.nextUrl

    // None mode: allow everything
    if (mode === 'none') {
      return NextResponse.next()
    }

    // Check if public route
    const isPublicRoute = publicRoutes.some(route =>
      pathname.startsWith(route) || pathname === route
    )

    if (isPublicRoute) {
      return NextResponse.next()
    }

    // Authentik mode: check real session
    const { auth } = await import('./providers/authentik')
    const session = await auth()

    if (!session) {
      const loginUrl = new URL(loginPath, request.url)
      loginUrl.searchParams.set('callbackUrl', pathname)
      return NextResponse.redirect(loginUrl)
    }

    return NextResponse.next()
  }
}

4. Authentik Provider (lib/auth/providers/authentik.ts)

import NextAuth from 'next-auth'
import type { NextAuthConfig } from 'next-auth'

const config: NextAuthConfig = {
  providers: [
    {
      id: 'authentik',
      name: 'Authentik',
      type: 'oidc',
      issuer: process.env.AUTHENTIK_ISSUER,
      clientId: process.env.AUTHENTIK_CLIENT_ID,
      clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
      authorization: { params: { scope: 'openid profile email groups' } },
      profile(profile) {
        return {
          id: profile.sub,
          name: profile.name || profile.preferred_username,
          email: profile.email,
          image: profile.picture,
          groups: profile.groups || [],
        }
      },
    },
  ],
  callbacks: {
    jwt({ token, user, account }) {
      if (account && user) {
        token.accessToken = account.access_token
        token.idToken = account.id_token
        token.groups = (user as any).groups || []
        token.authentikId = user.id
      }
      return token
    },
    session({ session, token }) {
      if (session.user) {
        session.user.id = token.authentikId as string
        session.user.groups = token.groups as string[]
      }
      session.accessToken = token.accessToken as string
      session.idToken = token.idToken as string
      return session
    },
  },
  pages: {
    signIn: '/login',
  },
  trustHost: true,
}

export const { auth, signIn, signOut, handlers } = NextAuth(config)

5. Dev Banner Component (components/auth/dev-banner.tsx)

'use client'

import { useEffect, useState } from 'react'

export function DevAuthBanner() {
  const [authMode, setAuthMode] = useState<string | null>(null)
  const [dismissed, setDismissed] = useState(false)

  useEffect(() => {
    fetch('/api/auth/mode')
      .then(res => res.json())
      .then(data => setAuthMode(data.mode))
      .catch(() => setAuthMode('unknown'))
  }, [])

  if (!authMode || authMode === 'authentik' || dismissed) {
    return null
  }

  return (
    <div className="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-yellow-950 text-center py-1.5 text-sm font-medium flex items-center justify-center gap-2">
      <span>Dev Mode: Authentication disabled (AUTH_MODE=none)</span>
      <button
        onClick={() => setDismissed(true)}
        className="ml-2 px-2 py-0.5 text-xs bg-yellow-600 hover:bg-yellow-700 text-yellow-50 rounded"
      >
        Dismiss
      </button>
    </div>
  )
}

Supabase RLS Integration

The auth bridge creates Supabase-compatible JWTs signed with SUPABASE_JWT_SECRET, allowing RLS policies to access user claims.

JWT Bridge (lib/auth/supabase-bridge.ts)

import { createClient } from '@supabase/supabase-js'
import type { SupabaseClient } from '@supabase/supabase-js'
import jwt from 'jsonwebtoken'
import { getAuthMode } from './config'

type AnySupabaseClient = SupabaseClient<any, any, any>

const tokenCache = new Map<string, { token: string; expiresAt: number }>()

interface User {
  id: string
  email?: string | null
  groups?: string[]
}

interface AuthContext {
  user: User | null
  supabase: AnySupabaseClient | null
  rlsEnabled: boolean
}

export async function getAuthContextWithRLS(): Promise<AuthContext> {
  const mode = getAuthMode()

  // None mode: no auth context
  if (mode === 'none') {
    return { user: null, supabase: null, rlsEnabled: false }
  }

  // Authentik mode: get real session
  const { auth } = await import('./providers/authentik')
  const session = await auth()

  if (!session?.user?.id) {
    return { user: null, supabase: null, rlsEnabled: false }
  }

  const user: User = {
    id: session.user.id,
    email: session.user.email,
    groups: session.user.groups || [],
  }

  // Create RLS-enabled client
  const supabase = await createAuthenticatedSupabaseClient(user)

  if (!supabase) {
    return { user, supabase: createServiceRoleClient(), rlsEnabled: false }
  }

  return { user, supabase, rlsEnabled: true }
}

async function createAuthenticatedSupabaseClient(user: User): Promise<AnySupabaseClient | null> {
  const supabaseJwt = createSupabaseJWT(user)
  if (!supabaseJwt) return null

  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      db: { schema: 'your_schema' },  // Replace with app schema
      global: { headers: { Authorization: `Bearer ${supabaseJwt}` } },
    }
  )
}

function createSupabaseJWT(user: User): string | null {
  const jwtSecret = process.env.SUPABASE_JWT_SECRET
  if (!jwtSecret) {
    console.error('[auth-bridge] SUPABASE_JWT_SECRET not configured')
    return null
  }

  const cacheKey = `${user.id}-${user.groups?.join(',') || ''}`
  const cached = tokenCache.get(cacheKey)

  if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1000) {
    return cached.token
  }

  const now = Math.floor(Date.now() / 1000)
  const payload = {
    iss: 'your-app-name',  // Replace with app name
    sub: user.id,
    aud: 'authenticated',
    iat: now,
    exp: now + 3600,
    role: 'authenticated',
    email: user.email,
    groups: user.groups || [],
  }

  try {
    const token = jwt.sign(payload, jwtSecret, { algorithm: 'HS256' })
    tokenCache.set(cacheKey, { token, expiresAt: Date.now() + 55 * 60 * 1000 })
    return token
  } catch (err) {
    console.error('[auth-bridge] Failed to create JWT:', err)
    return null
  }
}

export function createServiceRoleClient(): AnySupabaseClient {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { db: { schema: 'your_schema' } }  // Replace with app schema
  )
}

RBAC Helpers

Group-Based Access (lib/auth/rbac.ts)

interface User {
  groups?: string[]
}

// Standard tier hierarchy (customize per app)
export const TIERS = {
  OWNER: 'tier-owner',
  FAMILY: 'tier-family',
  FRIENDS: 'tier-friends',
  GUESTS: 'tier-guests',
} as const

export type Tier = (typeof TIERS)[keyof typeof TIERS]

export const TIER_HIERARCHY: Tier[] = [
  TIERS.OWNER,
  TIERS.FAMILY,
  TIERS.FRIENDS,
  TIERS.GUESTS,
]

export function isAdmin(user: User | null | undefined): boolean {
  return user?.groups?.includes(TIERS.OWNER) ?? false
}

export function hasGroup(user: User | null | undefined, group: string): boolean {
  return user?.groups?.includes(group) ?? false
}

export function hasAnyGroup(user: User | null | undefined, groups: string[]): boolean {
  if (!user?.groups) return false
  return groups.some(group => user.groups!.includes(group))
}

export function getUserTier(user: User | null | undefined): Tier | null {
  if (!user?.groups) return null

  for (const tier of TIER_HIERARCHY) {
    if (user.groups.includes(tier)) {
      return tier
    }
  }
  return null
}

export function hasTierAccess(user: User | null | undefined, requiredTier: Tier): boolean {
  const userTier = getUserTier(user)
  if (!userTier) return false

  const userTierIndex = TIER_HIERARCHY.indexOf(userTier)
  const requiredTierIndex = TIER_HIERARCHY.indexOf(requiredTier)

  return userTierIndex <= requiredTierIndex  // Lower index = higher privilege
}

Usage Examples

Server Component

import { auth } from '@/lib/auth'
import { isAdmin } from '@/lib/auth/rbac'
import { redirect } from 'next/navigation'

export default async function AdminPage() {
  const session = await auth()

  if (!session?.user || !isAdmin(session.user)) {
    redirect('/unauthorized')
  }

  return <div>Admin content</div>
}

Client Component

'use client'

import { useSession } from 'next-auth/react'
import { isAdmin } from '@/lib/auth/rbac'

export function AdminPanel() {
  const { data: session, status } = useSession()

  if (status === 'loading') return <div>Loading...</div>
  if (!session?.user || !isAdmin(session.user)) return null

  return <div>Admin panel</div>
}

API Route

import { NextResponse } from 'next/server'
import { getAuthContextWithRLS } from '@/lib/auth/supabase-bridge'

export async function GET() {
  const { user, supabase } = await getAuthContextWithRLS()

  if (!user || !supabase) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // RLS policies automatically filter based on user
  const { data, error } = await supabase.from('items').select('*')

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json(data)
}

Testing Different Auth Modes

Quick Mode Switching

# No auth - pure UI development (offline/no VPN)
AUTH_MODE=none npm run dev

# Real Authentik - integration testing (requires VPN to homelab)
AUTH_MODE=authentik npm run dev

Testing RBAC with Real Auth

Connect to homelab via VPN, then: - Log in as your admin account (tier-owner group) - Log in with a test user in different groups - Verify access controls work correctly

Authentik Application Setup

For each app using this pattern, create an Authentik application:

  1. Create OAuth2/OpenID Provider:
  2. Name: {app-name}-provider
  3. Authorization flow: default-authorization-flow
  4. Client ID: (auto-generated)
  5. Client Secret: (auto-generated)
  6. Redirect URIs:
    • http://localhost:3000/api/auth/callback/authentik
    • https://{app}.internal/api/auth/callback/authentik
  7. Scopes: openid profile email groups

  8. Create Application:

  9. Name: {app-name}
  10. Slug: {app-name}
  11. Provider: {app-name}-provider
  12. Launch URL: https://{app}.internal

  13. Configure Group Mappings (if using RBAC):

  14. Property Mappings should include groups claim
  15. Standard scope groups should be enabled

Migration Checklist

When migrating an existing app to this pattern:

  • [ ] Install dependencies: next-auth@beta jsonwebtoken
  • [ ] Copy auth file structure from template
  • [ ] Update .env.local with auth variables
  • [ ] Add AUTH_MODE validation to next.config.ts
  • [ ] Update root middleware.ts to use auth middleware
  • [ ] Wrap app with SessionProvider in root layout
  • [ ] Add DevAuthBanner component
  • [ ] Update API routes to use getAuthContextWithRLS()
  • [ ] Create Authentik application
  • [ ] Test both auth modes (none and authentik)
  • [ ] Update CI/CD to enforce AUTH_MODE=authentik in production