Replaced NextAuth's built-in OAuth provider (incompatible with Cloudflare Workers) with custom OAuth implementation using native fetch API. Features: - Custom OAuth flow compatible with Cloudflare Workers edge runtime - Auto-provisions users from Nextcloud based on group membership - Group-based role assignment (artists, shop_admins, admins) - Auto-creates artist profiles for users in 'artists' group - Seamless integration with existing NextAuth session management Technical changes: - Added custom OAuth routes: /api/auth/nextcloud/authorize & callback - Created Nextcloud API client for user provisioning (lib/nextcloud-client.ts) - Extended credentials provider to accept Nextcloud one-time tokens - Added user management functions to database layer - Updated signin UI to use custom OAuth flow - Added environment variables for OAuth configuration Documentation: - Comprehensive setup guide in docs/NEXTCLOUD-OAUTH-SETUP.md - Updated CLAUDE.md with new authentication architecture Fixes: NextAuth OAuth incompatibility with Cloudflare Workers (unenv https.request error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
322 lines
8.7 KiB
TypeScript
322 lines
8.7 KiB
TypeScript
import { NextAuthOptions } from "next-auth"
|
|
import GoogleProvider from "next-auth/providers/google"
|
|
import GitHubProvider from "next-auth/providers/github"
|
|
import CredentialsProvider from "next-auth/providers/credentials"
|
|
import { env } from "./env"
|
|
import { UserRole } from "@/types/database"
|
|
import {
|
|
getNextcloudUserProfile,
|
|
getNextcloudUserGroups,
|
|
determineUserRole
|
|
} from "./nextcloud-client"
|
|
|
|
export const authOptions: NextAuthOptions = {
|
|
// Note: Database adapter will be configured via Supabase MCP
|
|
// For now, using JWT strategy without database adapter
|
|
providers: [
|
|
// Credentials provider for email/password login (admin fallback) and Nextcloud OAuth completion
|
|
CredentialsProvider({
|
|
name: "credentials",
|
|
credentials: {
|
|
email: { label: "Email", type: "email" },
|
|
password: { label: "Password", type: "password" },
|
|
nextcloud_token: { label: "Nextcloud Token", type: "text" },
|
|
},
|
|
async authorize(credentials, req) {
|
|
console.log("Authorize called with:", credentials)
|
|
|
|
// Handle Nextcloud OAuth completion
|
|
if (credentials?.nextcloud_token) {
|
|
console.log("Nextcloud OAuth completion with token")
|
|
|
|
// Get cookies from request
|
|
const cookies = req.headers?.cookie
|
|
if (!cookies) {
|
|
console.error("No cookies found")
|
|
return null
|
|
}
|
|
|
|
// Parse cookies manually
|
|
const cookieMap = new Map(
|
|
cookies.split(';').map(c => {
|
|
const [key, ...values] = c.trim().split('=')
|
|
return [key, values.join('=')]
|
|
})
|
|
)
|
|
|
|
const storedToken = cookieMap.get('nextcloud_one_time_token')
|
|
const userId = cookieMap.get('nextcloud_user_id')
|
|
|
|
console.log("Stored token:", storedToken ? "present" : "missing")
|
|
console.log("User ID:", userId ? userId : "missing")
|
|
|
|
if (!storedToken || !userId || storedToken !== credentials.nextcloud_token) {
|
|
console.error("Token validation failed")
|
|
return null
|
|
}
|
|
|
|
// Fetch user from database
|
|
const { getUserById } = await import('@/lib/db')
|
|
const user = await getUserById(userId)
|
|
|
|
if (!user) {
|
|
console.error("User not found")
|
|
return null
|
|
}
|
|
|
|
console.log("Nextcloud user authenticated:", user.email)
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
}
|
|
}
|
|
|
|
// Handle regular credentials login
|
|
if (!credentials?.email || !credentials?.password) {
|
|
console.log("Missing email or password")
|
|
return null
|
|
}
|
|
|
|
console.log("Email received:", credentials.email)
|
|
console.log("Password received:", credentials.password ? "***" : "empty")
|
|
|
|
// Seed admin user for nicholai@biohazardvfx.com
|
|
if (credentials.email === "nicholai@biohazardvfx.com") {
|
|
console.log("Admin user recognized!")
|
|
return {
|
|
id: "admin-nicholai",
|
|
email: "nicholai@biohazardvfx.com",
|
|
name: "Nicholai",
|
|
role: UserRole.SUPER_ADMIN,
|
|
}
|
|
}
|
|
|
|
// For development: Accept any other email/password combination
|
|
console.log("Using fallback user creation")
|
|
const user = {
|
|
id: "dev-user-" + Date.now(),
|
|
email: credentials.email,
|
|
name: credentials.email.split("@")[0],
|
|
role: UserRole.SUPER_ADMIN, // Give admin access for testing
|
|
}
|
|
|
|
console.log("Created user:", user)
|
|
return user
|
|
}
|
|
}),
|
|
|
|
// Google OAuth provider (optional)
|
|
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? [
|
|
GoogleProvider({
|
|
clientId: env.GOOGLE_CLIENT_ID,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
})
|
|
] : []),
|
|
|
|
// GitHub OAuth provider (optional)
|
|
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? [
|
|
GitHubProvider({
|
|
clientId: env.GITHUB_CLIENT_ID,
|
|
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
})
|
|
] : []),
|
|
],
|
|
session: {
|
|
strategy: "jwt",
|
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
},
|
|
callbacks: {
|
|
async jwt({ token, user, account }) {
|
|
// Add user role to JWT token
|
|
if (user) {
|
|
// Use the role from the user object (set in authorize function)
|
|
token.role = (user as any).role || UserRole.CLIENT
|
|
token.userId = user.id
|
|
}
|
|
return token
|
|
},
|
|
async session({ session, token }) {
|
|
// Add user role and ID to session
|
|
if (token) {
|
|
session.user.id = token.userId as string
|
|
session.user.role = token.role as UserRole
|
|
}
|
|
return session
|
|
},
|
|
async signIn({ user, account, profile }) {
|
|
// Custom sign-in logic
|
|
// Note: Nextcloud OAuth auto-provisioning happens in custom callback handler
|
|
return true
|
|
},
|
|
async redirect({ url, baseUrl }) {
|
|
// Follows NextAuth.js best practices for redirect
|
|
if (url.startsWith("/")) return `${baseUrl}${url}`
|
|
else if (new URL(url).origin === baseUrl) return url
|
|
return `${baseUrl}/admin`
|
|
},
|
|
},
|
|
pages: {
|
|
signIn: "/auth/signin",
|
|
error: "/auth/error",
|
|
},
|
|
events: {
|
|
async signIn({ user, account, profile, isNewUser }) {
|
|
// Log sign-in events
|
|
console.log(`User ${user.email} signed in`)
|
|
},
|
|
async signOut({ session, token }) {
|
|
// Log sign-out events
|
|
console.log(`User signed out`)
|
|
},
|
|
},
|
|
debug: env.NODE_ENV === "development",
|
|
}
|
|
|
|
/**
|
|
* Utility function to get server-side session
|
|
*/
|
|
export async function getServerSession() {
|
|
const { getServerSession: getNextAuthServerSession } = await import("next-auth/next")
|
|
return getNextAuthServerSession(authOptions)
|
|
}
|
|
|
|
/**
|
|
* Route protection utility
|
|
* @param requiredRole - Minimum role required to access the route
|
|
*/
|
|
export async function requireAuth(requiredRole?: UserRole) {
|
|
const session = await getServerSession()
|
|
|
|
if (!session) {
|
|
throw new Error("Authentication required")
|
|
}
|
|
|
|
if (requiredRole && !hasRole(session.user.role, requiredRole)) {
|
|
throw new Error("Insufficient permissions")
|
|
}
|
|
|
|
return session
|
|
}
|
|
|
|
/**
|
|
* Check if user has required role or higher
|
|
*/
|
|
export function hasRole(userRole: UserRole, requiredRole: UserRole): boolean {
|
|
const roleHierarchy = {
|
|
[UserRole.CLIENT]: 0,
|
|
[UserRole.ARTIST]: 1,
|
|
[UserRole.SHOP_ADMIN]: 2,
|
|
[UserRole.SUPER_ADMIN]: 3,
|
|
}
|
|
|
|
return roleHierarchy[userRole] >= roleHierarchy[requiredRole]
|
|
}
|
|
|
|
/**
|
|
* Check if user is admin (SHOP_ADMIN or SUPER_ADMIN)
|
|
*/
|
|
export function isAdmin(role: UserRole): boolean {
|
|
return role === UserRole.SHOP_ADMIN || role === UserRole.SUPER_ADMIN
|
|
}
|
|
|
|
/**
|
|
* Check if user is super admin
|
|
*/
|
|
export function isSuperAdmin(role: UserRole): boolean {
|
|
return role === UserRole.SUPER_ADMIN
|
|
}
|
|
|
|
/**
|
|
* Get current artist session
|
|
* Returns the artist record and user data if the logged-in user is an artist
|
|
*/
|
|
export async function getArtistSession() {
|
|
const session = await getServerSession()
|
|
|
|
if (!session?.user) {
|
|
return null
|
|
}
|
|
|
|
// Check if user has ARTIST role
|
|
const userRole = session.user.role
|
|
if (userRole !== UserRole.ARTIST && !isAdmin(userRole)) {
|
|
return null
|
|
}
|
|
|
|
// Import db function dynamically to avoid circular dependencies
|
|
const { getArtistByUserId } = await import('@/lib/db')
|
|
const artist = await getArtistByUserId(session.user.id)
|
|
|
|
if (!artist) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
artist,
|
|
user: session.user
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require artist authentication
|
|
* Throws error if user is not an artist, otherwise returns artist and user data
|
|
*/
|
|
export async function requireArtistAuth() {
|
|
const artistSession = await getArtistSession()
|
|
|
|
if (!artistSession) {
|
|
throw new Error("Artist authentication required")
|
|
}
|
|
|
|
return artistSession
|
|
}
|
|
|
|
/**
|
|
* Check if a user can edit a specific artist profile
|
|
* Returns true if the user is the artist themselves, or has admin privileges
|
|
*/
|
|
export async function canEditArtist(userId: string, artistId: string): Promise<boolean> {
|
|
const session = await getServerSession()
|
|
|
|
if (!session?.user) {
|
|
return false
|
|
}
|
|
|
|
// Admins can edit any artist
|
|
if (isAdmin(session.user.role)) {
|
|
return true
|
|
}
|
|
|
|
// Check if this user owns the artist profile
|
|
const { getArtistByUserId } = await import('@/lib/db')
|
|
const artist = await getArtistByUserId(userId)
|
|
|
|
return artist?.id === artistId
|
|
}
|
|
|
|
// Extend NextAuth types
|
|
declare module "next-auth" {
|
|
interface Session {
|
|
user: {
|
|
id: string
|
|
email: string
|
|
name: string
|
|
image?: string
|
|
role: UserRole
|
|
}
|
|
}
|
|
|
|
interface User {
|
|
role: UserRole
|
|
}
|
|
}
|
|
|
|
declare module "next-auth/jwt" {
|
|
interface JWT {
|
|
userId: string
|
|
role: UserRole
|
|
}
|
|
}
|