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

195 lines
6.0 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { signIn } from 'next-auth/react'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { determineUserRole, getNextcloudUserProfile } from '@/lib/nextcloud-client'
import { getUserByEmail, createUser, createArtist } from '@/lib/db'
import { UserRole } from '@/types/database'
/**
* Custom Nextcloud OAuth Callback Handler
*
* Handles the OAuth callback from Nextcloud, exchanges code for token,
* fetches user info, auto-provisions users/artists, and creates a session.
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
// Handle OAuth errors
if (error) {
console.error('OAuth error:', error)
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
if (!code || !state) {
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
// Validate state (CSRF protection)
const cookieStore = await cookies()
const storedState = cookieStore.get('nextcloud_oauth_state')?.value
const callbackUrl = cookieStore.get('nextcloud_oauth_callback')?.value || '/admin'
if (!storedState || storedState !== state) {
console.error('State mismatch - possible CSRF attack')
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
// Clear state cookies
cookieStore.delete('nextcloud_oauth_state')
cookieStore.delete('nextcloud_oauth_callback')
const baseUrl = process.env.NEXTCLOUD_BASE_URL
const clientId = process.env.NEXTCLOUD_OAUTH_CLIENT_ID
const clientSecret = process.env.NEXTCLOUD_OAUTH_CLIENT_SECRET
if (!baseUrl || !clientId || !clientSecret) {
return NextResponse.json(
{ error: 'Nextcloud OAuth is not configured' },
{ status: 500 }
)
}
try {
// Step 1: Exchange authorization code for access token
const tokenUrl = `${baseUrl}/index.php/apps/oauth2/api/v1/token`
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${process.env.NEXTAUTH_URL}/api/auth/nextcloud/callback`,
client_id: clientId,
client_secret: clientSecret,
}),
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
console.error('Token exchange failed:', errorText)
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.access_token
// Step 2: Fetch user info from Nextcloud
const userInfoUrl = `${baseUrl}/ocs/v2.php/cloud/user?format=json`
const userInfoResponse = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
'OCS-APIRequest': 'true',
},
})
if (!userInfoResponse.ok) {
console.error('Failed to fetch user info')
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
const userInfoData = await userInfoResponse.json()
const userData = userInfoData.ocs?.data
if (!userData) {
console.error('Invalid user info response')
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
const userId = userData.id
const email = userData.email
const displayName = userData.displayname || userData['display-name'] || userId
console.log(`Nextcloud user authenticated: ${email} (${userId})`)
// Step 3: Determine role from Nextcloud groups
const role = await determineUserRole(userId)
// Prevent non-authorized users from signing in
if (role === UserRole.CLIENT) {
console.warn(`User ${email} is not in an authorized group`)
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
// Step 4: Auto-provision user if needed
let user = await getUserByEmail(email)
if (!user) {
console.log(`Creating new user for ${email} with role ${role}`)
user = await createUser({
email,
name: displayName,
role,
})
// If artist, create artist profile
if (role === UserRole.ARTIST) {
const artist = await createArtist({
userId: user.id,
name: displayName,
bio: '',
specialties: [],
instagramHandle: null,
hourlyRate: null,
isActive: true,
})
console.log(`Created artist profile ${artist.id}`)
}
} else {
console.log(`Existing user ${email} signed in`)
}
// Step 5: Create a one-time token for session completion
// Store user ID in a short-lived cookie that credentials provider will validate
const oneTimeToken = crypto.randomUUID()
cookieStore.set('nextcloud_user_id', user.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60, // 1 minute - just long enough to complete sign-in
path: '/',
})
cookieStore.set('nextcloud_one_time_token', oneTimeToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
// Redirect to completion page that will auto-submit to NextAuth
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/nextcloud/complete?token=${oneTimeToken}&callbackUrl=${encodeURIComponent(callbackUrl)}`
)
} catch (error) {
console.error('OAuth callback error:', error)
return NextResponse.redirect(
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
)
}
}