Initial commit
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
# Authentication Protection Patterns for Next.js
|
||||
|
||||
This reference document provides common authentication and authorization patterns for Next.js App Router applications.
|
||||
|
||||
## Pattern 1: Server Component Auth Check
|
||||
|
||||
Basic authentication check in Server Components.
|
||||
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error || !user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {user.email}</h1>
|
||||
{/* Protected content */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 2: API Route Auth Check
|
||||
|
||||
Protecting API routes with authentication.
|
||||
|
||||
```typescript
|
||||
// app/api/data/route.ts
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch user-specific data
|
||||
const { data } = await supabase
|
||||
.from('items')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Insert with user_id
|
||||
const { data, error: insertError } = await supabase
|
||||
.from('items')
|
||||
.insert([{ ...body, user_id: user.id }])
|
||||
.select()
|
||||
|
||||
if (insertError) {
|
||||
return NextResponse.json(
|
||||
{ error: insertError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 3: Server Action Protection
|
||||
|
||||
Securing server actions with auth and validation.
|
||||
|
||||
```typescript
|
||||
// lib/actions/posts.ts
|
||||
'use server'
|
||||
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
|
||||
const postSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
// 1. Auth check
|
||||
const supabase = createServerClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error || !user) {
|
||||
return { error: 'Unauthorized' }
|
||||
}
|
||||
|
||||
// 2. Input validation
|
||||
const rawData = {
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
}
|
||||
|
||||
const validatedData = postSchema.safeParse(rawData)
|
||||
|
||||
if (!validatedData.success) {
|
||||
return { error: 'Invalid input', details: validatedData.error }
|
||||
}
|
||||
|
||||
// 3. Perform action
|
||||
const { data, error: insertError } = await supabase
|
||||
.from('posts')
|
||||
.insert([{
|
||||
...validatedData.data,
|
||||
user_id: user.id,
|
||||
}])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
return { error: insertError.message }
|
||||
}
|
||||
|
||||
// 4. Revalidate
|
||||
revalidatePath('/posts')
|
||||
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updatePost(postId: string, formData: FormData) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error || !user) {
|
||||
return { error: 'Unauthorized' }
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const { data: post } = await supabase
|
||||
.from('posts')
|
||||
.select('user_id')
|
||||
.eq('id', postId)
|
||||
.single()
|
||||
|
||||
if (!post || post.user_id !== user.id) {
|
||||
return { error: 'Forbidden' }
|
||||
}
|
||||
|
||||
// Validate and update
|
||||
const rawData = {
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
}
|
||||
|
||||
const validatedData = postSchema.safeParse(rawData)
|
||||
|
||||
if (!validatedData.success) {
|
||||
return { error: 'Invalid input' }
|
||||
}
|
||||
|
||||
const { data, error: updateError } = await supabase
|
||||
.from('posts')
|
||||
.update(validatedData.data)
|
||||
.eq('id', postId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return { error: updateError.message }
|
||||
}
|
||||
|
||||
revalidatePath('/posts')
|
||||
revalidatePath(`/posts/${postId}`)
|
||||
|
||||
return { data }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 4: Role-Based Access Control (RBAC)
|
||||
|
||||
Check user roles for access control.
|
||||
|
||||
```typescript
|
||||
// lib/auth/roles.ts
|
||||
export type Role = 'user' | 'moderator' | 'admin'
|
||||
|
||||
export async function getUserRole(userId: string): Promise<Role | null> {
|
||||
const supabase = createServerClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
|
||||
return data?.role || null
|
||||
}
|
||||
|
||||
export function canAccessRoute(userRole: Role, requiredRoles: Role[]): boolean {
|
||||
return requiredRoles.includes(userRole)
|
||||
}
|
||||
|
||||
// Usage in server component
|
||||
export default async function AdminPage() {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const role = await getUserRole(user.id)
|
||||
|
||||
if (!canAccessRoute(role, ['admin'])) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
|
||||
return <div>Admin Content</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 5: Permission-Based Access Control
|
||||
|
||||
Fine-grained permission checking.
|
||||
|
||||
```typescript
|
||||
// lib/auth/permissions.ts
|
||||
export type Permission =
|
||||
| 'posts:read'
|
||||
| 'posts:create'
|
||||
| 'posts:update'
|
||||
| 'posts:delete'
|
||||
| 'users:read'
|
||||
| 'users:update'
|
||||
| 'users:delete'
|
||||
|
||||
export async function hasPermission(
|
||||
userId: string,
|
||||
permission: Permission
|
||||
): Promise<boolean> {
|
||||
const supabase = createServerClient()
|
||||
|
||||
// Check user's role-based permissions
|
||||
const { data: role } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
|
||||
// Admin has all permissions
|
||||
if (role?.role === 'admin') return true
|
||||
|
||||
// Check specific permission
|
||||
const { data } = await supabase
|
||||
.from('user_permissions')
|
||||
.select('permission')
|
||||
.eq('user_id', userId)
|
||||
.eq('permission', permission)
|
||||
.single()
|
||||
|
||||
return !!data
|
||||
}
|
||||
|
||||
// Usage in server action
|
||||
export async function deleteUser(targetUserId: string) {
|
||||
'use server'
|
||||
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { error: 'Unauthorized' }
|
||||
}
|
||||
|
||||
const canDelete = await hasPermission(user.id, 'users:delete')
|
||||
|
||||
if (!canDelete) {
|
||||
return { error: 'Insufficient permissions' }
|
||||
}
|
||||
|
||||
// Perform deletion
|
||||
const { error } = await supabase
|
||||
.from('users')
|
||||
.delete()
|
||||
.eq('id', targetUserId)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/admin/users')
|
||||
return { success: true }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 6: Middleware Route Protection
|
||||
|
||||
Protect multiple routes with middleware.
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
import { createServerClient } from '@/lib/supabase/middleware'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const publicRoutes = ['/', '/about', '/login', '/signup']
|
||||
const protectedRoutes = ['/dashboard', '/profile', '/settings']
|
||||
const adminRoutes = ['/admin']
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next()
|
||||
const supabase = createServerClient(request, response)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
// Allow public routes
|
||||
if (publicRoutes.includes(pathname)) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Check if route requires auth
|
||||
const requiresAuth = protectedRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
)
|
||||
|
||||
if (requiresAuth && !user) {
|
||||
const redirectUrl = new URL('/login', request.url)
|
||||
redirectUrl.searchParams.set('redirect', pathname)
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
}
|
||||
|
||||
// Check admin routes
|
||||
const requiresAdmin = adminRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
)
|
||||
|
||||
if (requiresAdmin) {
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (profile?.role !== 'admin') {
|
||||
return NextResponse.redirect(new URL('/unauthorized', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 7: Layout-Based Protection
|
||||
|
||||
Protect all routes within a layout.
|
||||
|
||||
```typescript
|
||||
// app/(protected)/layout.tsx
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function ProtectedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// All pages under app/(protected)/ are now protected
|
||||
```
|
||||
|
||||
## Pattern 8: Ownership Verification
|
||||
|
||||
Verify user owns a resource before allowing access.
|
||||
|
||||
```typescript
|
||||
// app/posts/[id]/edit/page.tsx
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { redirect, notFound } from 'next/navigation'
|
||||
|
||||
export default async function EditPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string }
|
||||
}) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) redirect('/login')
|
||||
|
||||
// Fetch post and verify ownership
|
||||
const { data: post, error } = await supabase
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single()
|
||||
|
||||
if (error || !post) notFound()
|
||||
|
||||
if (post.user_id !== user.id) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
|
||||
return <EditPostForm post={post} />
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 9: Multi-Tenant Isolation
|
||||
|
||||
Ensure users can only access their tenant's data.
|
||||
|
||||
```typescript
|
||||
// lib/auth/tenant.ts
|
||||
export async function requireTenantAccess(tenantId: string) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Check tenant membership
|
||||
const { data: membership } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('role')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!membership) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
|
||||
return { user, role: membership.role }
|
||||
}
|
||||
|
||||
// Usage
|
||||
export default async function TenantPage({
|
||||
params,
|
||||
}: {
|
||||
params: { tenantId: string }
|
||||
}) {
|
||||
const { user, role } = await requireTenantAccess(params.tenantId)
|
||||
|
||||
// User has access to this tenant
|
||||
return <div>Tenant Content</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 10: Helper Functions
|
||||
|
||||
Reusable auth utilities.
|
||||
|
||||
```typescript
|
||||
// lib/auth/helpers.ts
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { cache } from 'react'
|
||||
|
||||
// Cache the current user for the request
|
||||
export const getCurrentUser = cache(async () => {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
return user
|
||||
})
|
||||
|
||||
// Require authentication or redirect
|
||||
export async function requireAuth() {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// Require specific role
|
||||
export async function requireRole(allowedRoles: string[]) {
|
||||
const user = await requireAuth()
|
||||
const supabase = createServerClient()
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (!profile || !allowedRoles.includes(profile.role)) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
|
||||
return { user, role: profile.role }
|
||||
}
|
||||
|
||||
// Get user with profile
|
||||
export async function getUserWithProfile() {
|
||||
const user = await requireAuth()
|
||||
const supabase = createServerClient()
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
return { user, profile }
|
||||
}
|
||||
|
||||
// Check if user can perform action
|
||||
export async function canPerformAction(
|
||||
action: string,
|
||||
resourceId?: string
|
||||
): Promise<boolean> {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!user) return false
|
||||
|
||||
// Check permissions logic
|
||||
const supabase = createServerClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.rpc('check_user_permission', {
|
||||
p_user_id: user.id,
|
||||
p_action: action,
|
||||
p_resource_id: resourceId,
|
||||
})
|
||||
|
||||
return !!data
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 11: Error Handling
|
||||
|
||||
Proper error handling for auth failures.
|
||||
|
||||
```typescript
|
||||
// app/api/protected/route.ts
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error) {
|
||||
console.error('Auth error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication failed' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - Please log in' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('items')
|
||||
.select('*')
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Database error:', fetchError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (error) {
|
||||
console.error('Unexpected error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 12: Rate Limiting with Auth
|
||||
|
||||
Combine auth with rate limiting.
|
||||
|
||||
```typescript
|
||||
// lib/rate-limit.ts
|
||||
import { Ratelimit } from '@upstash/ratelimit'
|
||||
import { Redis } from '@upstash/redis'
|
||||
|
||||
const ratelimit = new Ratelimit({
|
||||
redis: Redis.fromEnv(),
|
||||
limiter: Ratelimit.slidingWindow(10, '10 s'),
|
||||
})
|
||||
|
||||
export async function checkRateLimit(identifier: string) {
|
||||
const { success, limit, reset, remaining } = await ratelimit.limit(
|
||||
identifier
|
||||
)
|
||||
|
||||
return {
|
||||
success,
|
||||
limit,
|
||||
reset,
|
||||
remaining,
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in API route
|
||||
export async function POST(request: Request) {
|
||||
const supabase = createServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Rate limit by user ID
|
||||
const rateLimitResult = await checkRateLimit(user.id)
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Process request
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Auth Protection
|
||||
|
||||
Always test auth protection:
|
||||
|
||||
```typescript
|
||||
// tests/auth.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { GET } from '@/app/api/protected/route'
|
||||
|
||||
describe('Auth Protection', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
const request = new Request('http://localhost/api/protected')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const json = await response.json()
|
||||
expect(json.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('allows authenticated requests', async () => {
|
||||
// Mock authenticated request
|
||||
const request = new Request('http://localhost/api/protected', {
|
||||
headers: {
|
||||
Authorization: 'Bearer valid-token',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await GET(request)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Always validate on server**: Never trust client-side auth
|
||||
2. **Check ownership**: Verify user owns resource before modification
|
||||
3. **Use RLS**: Combine with Row-Level Security in database
|
||||
4. **Rate limit**: Prevent abuse of authenticated endpoints
|
||||
5. **Audit log**: Log authentication failures and privileged actions
|
||||
6. **Expire sessions**: Implement session timeouts
|
||||
7. **HTTPS only**: Never send auth tokens over HTTP
|
||||
8. **CSRF protection**: Use CSRF tokens for state-changing operations
|
||||
Reference in New Issue
Block a user