plebdevs/src/pages/api/auth/[...nextauth].js
2025-01-10 14:39:45 -06:00

392 lines
15 KiB
JavaScript

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GithubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "@/db/prisma";
import nodemailer from 'nodemailer';
import { findKind0Fields } from "@/utils/nostr";
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { bytesToHex } from '@noble/hashes/utils';
import { updateUser, getUserByPubkey, createUser, getUserById, getUserByEmail } from "@/db/models/userModels";
import { createRole } from "@/db/models/roleModels";
import appConfig from "@/config/appConfig";
import NDK from "@nostr-dev-kit/ndk";
import { nip19 } from 'nostr-tools';
// Initialize NDK for Nostr interactions
const ndk = new NDK({
explicitRelayUrls: appConfig.defaultRelayUrls
});
/**
* Handles Nostr profile synchronization and user creation/update
* @param {string} pubkey - User's public key
* @returns {Promise<Object|null>} User object or null if failed
*/
const syncNostrProfile = async (pubkey) => {
await ndk.connect();
const user = ndk.getUser({ pubkey });
try {
const profile = await user.fetchProfile();
const fields = await findKind0Fields(profile);
let dbUser = await getUserByPubkey(pubkey);
if (dbUser) {
// Update existing user if kind0 fields differ
if (fields.avatar !== dbUser.avatar || fields.username !== dbUser.username) {
const updates = {
...(fields.avatar !== dbUser.avatar && { avatar: fields.avatar }),
...(fields.username !== dbUser.username && {
username: fields.username,
name: fields.username
})
};
await updateUser(dbUser.id, updates);
dbUser = await getUserByPubkey(pubkey);
}
} else {
// Create new user
const username = fields.username || pubkey.slice(0, 8);
const payload = {
pubkey,
username,
avatar: fields.avatar,
name: username
};
dbUser = await createUser(payload);
// Create author role if applicable
if (appConfig.authorPubkeys.includes(pubkey)) {
const role = await createRole({
userId: dbUser.id,
admin: true,
subscribed: false,
});
if (role) {
await updateUser(dbUser.id, { role: role.id });
dbUser = await getUserByPubkey(pubkey);
}
}
}
return { ...dbUser, kind0: fields };
} catch (error) {
console.error("Nostr profile sync error:", error);
return null;
}
};
/**
* Generates an ephemeral keypair for non-Nostr login methods
* @returns {Object} Object containing public and private keys
*/
const generateEphemeralKeypair = () => {
const privkey = generateSecretKey();
const pubkey = getPublicKey(privkey);
// pubkey is hex, privkey is bytes need to convert to hex
return {
pubkey,
privkey: bytesToHex(privkey)
};
};
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
// Nostr login provider
CredentialsProvider({
id: "nostr",
name: "Nostr",
credentials: {
pubkey: { label: "Public Key", type: "text" }
},
authorize: async (credentials) => {
if (!credentials?.pubkey) return null;
return await syncNostrProfile(credentials.pubkey);
}
}),
// Anonymous login provider
CredentialsProvider({
id: "anonymous",
name: "Anonymous",
credentials: {
pubkey: { label: "Public Key", type: "text" },
privkey: { label: "Private Key", type: "text" }
},
authorize: async (credentials) => {
const keys = (credentials?.pubkey && credentials?.pubkey !== 'null' && credentials?.privkey && credentials?.privkey !== 'null') ?
{ pubkey: credentials.pubkey, privkey: credentials.privkey } :
generateEphemeralKeypair();
let user = await getUserByPubkey(keys.pubkey);
if (!user) {
user = await createUser({
...keys,
username: `anon-${keys.pubkey.slice(0, 8)}`,
name: `anon-${keys.pubkey.slice(0, 8)}`
});
}
return { ...user, privkey: keys.privkey };
}
}),
// Email provider with simpler configuration
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD
}
},
from: process.env.EMAIL_FROM
}),
// Github provider with ephemeral keypair generation
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
profile: async (profile) => {
const keys = generateEphemeralKeypair();
return {
id: profile.id.toString(),
pubkey: keys.pubkey,
privkey: keys.privkey,
name: profile.login,
email: profile.email,
avatar: profile.avatar_url
};
}
}),
// Recovery provider
CredentialsProvider({
id: "recovery",
name: "Recovery",
credentials: {
nsec: { label: "Recovery Key (nsec or hex)", type: "text" }
},
authorize: async (credentials) => {
if (!credentials?.nsec) return null;
try {
// Convert nsec to hex if needed
let privkeyHex = credentials.nsec;
if (credentials.nsec.startsWith('nsec')) {
try {
const { data: decodedPrivkey } = nip19.decode(credentials.nsec);
privkeyHex = Buffer.from(decodedPrivkey).toString('hex');
} catch (error) {
console.error("Invalid nsec format:", error);
return null;
}
}
// Find user with matching privkey
const user = await prisma.user.findFirst({
where: { privkey: privkeyHex },
include: {
role: true,
purchased: true,
userCourses: true,
userLessons: true,
nip05: true,
lightningAddress: true,
userBadges: true
}
});
if (!user) {
console.error("No user found with provided recovery key");
return null;
}
return user;
} catch (error) {
console.error("Recovery authorization error:", error);
return null;
}
}
})
],
callbacks: {
// Move email handling to the signIn callback
async signIn({ user, account }) {
// Only handle email provider sign ins
if (account?.provider === "email") {
try {
// Check if this is an existing user
const existingUser = await getUserByEmail(user.email);
if (!existingUser && user) {
// First time login: generate keypair
const keys = generateEphemeralKeypair();
const newUser = {
pubkey: keys.pubkey,
privkey: keys.privkey,
username: user.email.split('@')[0],
email: user.email,
avatar: user.image,
name: user.email.split('@')[0],
}
// Update the user with the new keypair
const createdUser = await createUser(newUser);
return createdUser;
} else {
console.log("User already exists", existingUser);
}
return true;
} catch (error) {
console.error("Email sign in error:", error);
return false;
}
}
return true; // Allow other provider sign ins
},
async session({ session, user, token }) {
const userData = token.user || user;
if (userData) {
const fullUser = await getUserById(userData.id);
// Get the user's GitHub account if it exists
const githubAccount = await prisma.account.findFirst({
where: {
userId: fullUser.id,
provider: 'github'
}
});
session.user = {
...session.user,
id: fullUser.id,
pubkey: fullUser.pubkey,
privkey: fullUser.privkey,
role: fullUser.role,
username: fullUser.username,
avatar: fullUser.avatar,
name: fullUser.name,
userCourses: fullUser.userCourses,
userLessons: fullUser.userLessons,
purchased: fullUser.purchased,
nip05: fullUser.nip05,
lightningAddress: fullUser.lightningAddress,
githubUsername: token.githubUsername,
createdAt: fullUser.createdAt,
userBadges: fullUser.userBadges
};
// Add GitHub account info to session if it exists
if (githubAccount) {
session.account = {
provider: githubAccount.provider,
type: githubAccount.type,
providerAccountId: githubAccount.providerAccountId,
access_token: githubAccount.access_token,
token_type: githubAccount.token_type,
scope: githubAccount.scope,
};
}
}
return session;
},
async jwt({ token, user, account, profile, session }) {
// If we are linking a github account to an existing email or anon account (we have privkey)
if (account?.provider === "github" && user?.id && user?.pubkey && user?.privkey) {
try {
// First update the user's profile with GitHub info
const updatedUser = await updateUser(user.id, {
name: profile?.login || profile?.name,
username: profile?.login || profile?.name,
avatar: profile?.avatar_url,
image: profile?.avatar_url,
});
// Get the updated user
const existingUser = await getUserById(updatedUser?.id);
if (existingUser) {
token.user = existingUser;
}
// add github username to token
token.githubUsername = profile?.login || profile?.name;
} catch (error) {
console.error("Error linking GitHub account:", error);
}
}
// nostr login (we have no privkey)
if (account?.provider === "github" && user?.id && user?.pubkey) {
try {
// First check if there's already a GitHub account linked
const existingGithubAccount = await prisma.account.findFirst({
where: {
userId: user.id,
provider: 'github'
}
});
// add github username to token
token.githubUsername = profile?.login || profile?.name;
if (!existingGithubAccount) {
// Update user profile with GitHub info
const updatedUser = await updateUser(user.id, {
name: profile?.login || profile?.name,
username: profile?.login || profile?.name,
avatar: profile?.avatar_url,
image: profile?.avatar_url,
email: profile?.email // Add email if user wants it
});
// Create the GitHub account link
await prisma.account.create({
data: {
userId: user.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
token_type: account.token_type,
scope: account.scope
}
});
// Get the updated user
const existingUser = await getUserById(updatedUser?.id);
if (existingUser) {
token.user = existingUser;
}
}
} catch (error) {
console.error("Error linking GitHub account:", error);
}
}
if (user) {
token.user = user;
}
if (account) {
token.account = account;
}
return token;
}
},
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
debug: process.env.NODE_ENV === 'development',
};
export default NextAuth(authOptions);