united-tattoo/lib/auth.ts
Nicholai 0d38f81e2c feat(auth): implement custom Nextcloud OAuth with auto-provisioning
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>
2025-10-23 02:06:14 +00:00

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
}
}