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>
195 lines
6.0 KiB
TypeScript
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`
|
|
)
|
|
}
|
|
}
|