diff --git a/src/components/profile/UserProfile.js b/src/components/profile/UserProfile.js index a59b2e3..a8f562c 100644 --- a/src/components/profile/UserProfile.js +++ b/src/components/profile/UserProfile.js @@ -134,7 +134,7 @@ const UserProfile = () => { )} {account && account?.provider === "github" ? ( - + ) : ( )} diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 4936265..0d82f1d 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -235,28 +235,40 @@ export const expireUserSubscriptions = async (userIds) => { }; export const getUserByEmail = async (email) => { - return await prisma.user.findUnique({ - where: { email }, - include: { - role: true, - purchased: { - include: { - course: true, - resource: true, - }, - }, - userCourses: { - include: { - course: true, - }, - }, - userLessons: { - include: { - lesson: true, - }, - }, - nip05: true, - lightningAddress: true, - }, - }); + if (!email || typeof email !== 'string') { + console.error('Invalid email parameter:', email); + return null; + } + + try { + return await prisma.user.findUnique({ + where: { + email: email.toLowerCase().trim() + }, + include: { + role: true, + purchased: { + include: { + course: true, + resource: true, + }, + }, + userCourses: { + include: { + course: true, + }, + }, + userLessons: { + include: { + lesson: true, + }, + }, + nip05: true, + lightningAddress: true, + }, + }); + } catch (error) { + console.error('Error in getUserByEmail:', error); + return null; + } }; diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index ae05d3d..180d204 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -1,109 +1,140 @@ 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 } from "@/db/models/userModels"; +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 GithubProvider from "next-auth/providers/github"; import NDK from "@nostr-dev-kit/ndk"; -// todo: currently email accounts ephemeral privkey gets saved to db but not anon user, is this required at all given the newer auth setup? - +// Initialize NDK for Nostr interactions const ndk = new NDK({ explicitRelayUrls: appConfig.defaultRelayUrls -}) +}); -const authorize = async (pubkey) => { +/** + * Handles Nostr profile synchronization and user creation/update + * @param {string} pubkey - User's public key + * @returns {Promise} User object or null if failed + */ +const syncNostrProfile = async (pubkey) => { await ndk.connect(); const user = ndk.getUser({ pubkey }); try { const profile = await user.fetchProfile(); - - // Check if user exists, create if not + const fields = await findKind0Fields(profile); let dbUser = await getUserByPubkey(pubkey); if (dbUser) { - const fields = await findKind0Fields(profile); - // Only update 'avatar' or 'username' if they are different from kind0 fields on the dbUser - if (fields.avatar !== dbUser.avatar) { - const updatedUser = await updateUser(dbUser.id, { avatar: fields.avatar }); - if (updatedUser) { - dbUser = await getUserByPubkey(pubkey); - } - } else if (fields.username !== dbUser.username) { - const updatedUser = await updateUser(dbUser.id, { username: fields.username, name: fields.username }); - if (updatedUser) { - dbUser = await getUserByPubkey(pubkey); - } + // 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); } - // add the kind0 fields to the user - const combinedUser = { ...dbUser, kind0: fields }; - - return combinedUser; } else { - // Create user - if (profile) { - const fields = await findKind0Fields(profile); - const payload = { pubkey, username: fields.username, avatar: fields.avatar, name: fields.username }; + // Create new user + const username = fields.username || pubkey.slice(0, 8); + const payload = { + pubkey, + username, + avatar: fields.avatar, + name: username + }; - if (appConfig.authorPubkeys.includes(pubkey)) { - // create a new author role for this user - const createdUser = await createUser(payload); - const role = await createRole({ - userId: createdUser.id, - admin: true, - subscribed: false, - }); + dbUser = await createUser(payload); - if (!role) { - console.error("Failed to create role"); - return null; - } - - const updatedUser = await updateUser(createdUser.id, { role: role.id }); - if (!updatedUser) { - console.error("Failed to update user"); - return null; - } - - const fullUser = await getUserByPubkey(pubkey); - - return { ...fullUser, kind0: fields }; - } else { - dbUser = await createUser(payload); - return { ...dbUser, kind0: fields }; + // 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 login error:", error); + console.error("Nostr profile sync error:", error); + return null; } - 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" }, + pubkey: { label: "Public Key", type: "text" } }, authorize: async (credentials) => { - if (credentials?.pubkey) { - return await authorize(credentials.pubkey); - } - return null; - }, + 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, @@ -113,263 +144,125 @@ export const authOptions = { pass: process.env.EMAIL_SERVER_PASSWORD } }, - from: process.env.EMAIL_FROM, - sendVerificationRequest: async ({ identifier, url, provider }) => { - // Use nodemailer to send the email - const transport = nodemailer.createTransport(provider.server); - await transport.sendMail({ - to: identifier, - from: provider.from, - subject: `Sign in to ${new URL(url).host}`, - text: `Sign in to ${new URL(url).host}\n${url}\n\n`, - html: `Sign in to ${new URL(url).host}Sign in`, - }); - } + 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 - } + }; } - }), - CredentialsProvider({ - id: "anonymous", - name: "Anonymous", - credentials: { - pubkey: { label: "Public Key", type: "text" }, - privkey: { label: "Private Key", type: "text" }, - }, - authorize: async (credentials) => { - let pubkey, privkey; - - if (credentials?.pubkey && credentials?.pubkey !== "null" && credentials?.privkey && credentials?.privkey !== "null") { - // Use provided keys - pubkey = credentials.pubkey; - privkey = credentials.privkey; - } else { - // Generate new keys - const sk = generateSecretKey(); - pubkey = getPublicKey(sk); - privkey = bytesToHex(sk); - } - - // Check if user exists in the database - let dbUser = await getUserByPubkey(pubkey); - - if (!dbUser) { - // Create new user if not exists - dbUser = await createUser({ - pubkey: pubkey, - username: pubkey.slice(0, 8), - }); - } else { - // Check if this user has a linked GitHub account - const githubAccount = await prisma.account.findFirst({ - where: { - userId: dbUser.id, - provider: 'github' - }, - include: { - user: true - } - }); - - if (githubAccount) { - // Return the user with GitHub provider information - return { - ...dbUser, - pubkey, - privkey, - // Add these fields to switch to GitHub provider - provider: 'github', - type: 'oauth', - providerAccountId: githubAccount.providerAccountId - }; - } - } - - // Return user object with pubkey and privkey - return { ...dbUser, pubkey, privkey }; - }, - }), + }) ], callbacks: { - async jwt({ token, user, account, trigger, profile }) { - if (user?.provider === 'github') { - // User has a linked GitHub account, use that as the primary provider - token.account = { - provider: 'github', - type: 'oauth', - providerAccountId: user.providerAccountId + // Move email handling to the signIn callback + async signIn({ user, account, profile, email }) { + // 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); + console.log("Created user", createdUser); + 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 }; - // Add GitHub profile information - token.githubProfile = { - login: user.username, - avatar_url: user.avatar, - email: user.email - }; - } else if (account) { - // Store GitHub-specific information - if (account.provider === 'github') { - token.account = account; - token.githubProfile = { - login: profile?.login, - avatar_url: profile?.avatar_url, - email: profile?.email, + + // 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, }; } } - - if (trigger === "update" && account?.provider !== "anonymous") { - // if we trigger an update call the authorize function again - const newUser = await authorize(token.user.pubkey); - token.user = newUser; - } - - // if we sign up with email and we don't have a pubkey or privkey, we need to generate them - if (trigger === "signUp" && account?.provider === "email" && !user.pubkey && !user.privkey) { - const sk = generateSecretKey(); - const pubkey = getPublicKey(sk); - const privkey = bytesToHex(sk); - - // Update the user in the database - await prisma.user.update({ - where: { id: user.id }, - data: { pubkey, privkey } - }); - - // Update the user object - user.pubkey = pubkey; - user.privkey = privkey; - } - - // Add new condition for first-time GitHub sign up - if (trigger === "signUp" && account?.provider === "github") { - const sk = generateSecretKey(); - const pubkey = getPublicKey(sk); - const privkey = bytesToHex(sk); - - // Use GitHub login (username) from the profile stored in token - const githubUsername = token.githubProfile?.login; - - // Update the user in the database with all GitHub details - await prisma.user.update({ - where: { id: user.id }, - data: { - pubkey, - privkey, - name: githubUsername, - username: githubUsername, - avatar: token.githubProfile?.avatar_url || null, - email: token.githubProfile?.email, - } - }); - - // Update the user object with all credentials - user.pubkey = pubkey; - user.privkey = privkey; - user.name = githubUsername; - user.username = githubUsername; - user.avatar = token.githubProfile?.avatar_url; - user.email = token.githubProfile?.email; - } - - if (account && account.provider === "github" && user?.id && user?.pubkey) { - // we are linking a github account to an existing account - const updatedUser = await updateUser(user.id, { - name: profile?.login, - username: profile?.login, - avatar: profile?.avatar_url, - email: profile?.email, - privkey: user.privkey || token.privkey, // Preserve the existing privkey - image: profile?.avatar_url, // Also save to image field - }); - - if (updatedUser) { - user = await getUserById(user.id); - } - } - - if (user) { - token.user = user; - if (user.pubkey && user.privkey) { - token.pubkey = user.pubkey; - token.privkey = user.privkey; - } - } - if (account?.provider === 'anonymous') { - token.isAnonymous = true; - } - return token; - }, - async session({ session, token }) { - // If this is a GitHub session, get the full user data from DB first - if (token.account?.provider === 'github') { - const dbUser = await getUserById(token.user.id); - - // Start with the complete DB user as the base - session.user = dbUser; - - // Override only the GitHub-specific fields - session.user = { - ...dbUser, // This includes role, purchases, userCourses, userLessons, etc. - username: token.githubProfile?.login, - name: token.githubProfile?.login, - avatar: token.githubProfile?.avatar_url, - email: token.githubProfile?.email - }; - } else { - // For non-GitHub sessions, use the existing token.user - session.user = token.user; - } - - // Keep the rest of the session data - if (token.account) { - session.account = token.account; - if (token.account.provider === 'github') { - session.githubProfile = token.githubProfile; - } - } - if (token.pubkey && token.privkey) { - session.pubkey = token.pubkey; - session.privkey = token.privkey; - } - session.isAnonymous = token.isAnonymous; return session; }, - async redirect({ url, baseUrl }) { - return baseUrl; - }, - async signOut({ token, session }) { - token = {} - session = {} - return true - }, - async signIn({ user, account }) { - if (account.provider === 'anonymous') { - return { - ...user, - pubkey: user.pubkey, - privkey: user.privkey, - }; + async jwt({ token, user, account }) { + if (user) { + token.user = user; } - return true; - }, + // Also store the account info in the token if it exists + if (account) { + token.account = account; + } + return token; + } }, - secret: process.env.NEXTAUTH_SECRET, - session: { strategy: "jwt" }, - jwt: { - signingKey: process.env.JWT_SECRET, + session: { + strategy: 'jwt', + maxAge: 30 * 24 * 60 * 60, // 30 days }, - pages: { - signIn: "/auth/signin", - } + debug: process.env.NODE_ENV === 'development', }; export default NextAuth(authOptions); diff --git a/src/pages/api/auth/auth.md b/src/pages/api/auth/auth.md new file mode 100644 index 0000000..9b31731 --- /dev/null +++ b/src/pages/api/auth/auth.md @@ -0,0 +1,13 @@ +Any account type is backed by a nostr keypair: +- email (ephemeral keypair + email address) +- Github (ephemeral keypair + basic github account info and permissions to read data from API) +- anon (is only ephemeral keypair) +- Login with nostr (not ephemeral keypair, this is the users keypair, we only have access to private key through web extension interface) + +Any time a user signs in, we try to pull the acount from the db, and add all of the data we can from the users record into their session. +If the user does not have an account in the db we create one for them and return it in a signed in state. +If the users does not have an account and they are signing up anon/github/email we must generate an ephemeral keypair for them and save it to the db (otherwise for nostr login user is bringing their keypair in whcih case we only need to save the pubkey) + +Here is another consideration, when a user is signing in via nostr, we want to pull their latest kind0 info and treat that as the latest and greatest. If they have a record in the db we want to update it if the name or image has changed. If they do not have a record we create one with their nostr image and username (or first 8 chars of pubkey if there is no name) + +Finally. It is possible to link github to an existing account in whcih case the user can sign in with either github or anon and it will pull the correct recrod. \ No newline at end of file diff --git a/src/pages/auth/signin.js b/src/pages/auth/signin.js index 692a416..1b7b079 100644 --- a/src/pages/auth/signin.js +++ b/src/pages/auth/signin.js @@ -38,28 +38,42 @@ export default function SignIn() { const storedPubkey = localStorage.getItem('anonymousPubkey') const storedPrivkey = localStorage.getItem('anonymousPrivkey') - const result = await signIn("anonymous", { - pubkey: storedPubkey, - privkey: storedPrivkey, - redirect: false - }) + try { + const result = await signIn("anonymous", { + pubkey: storedPubkey, + privkey: storedPrivkey, + redirect: false, + callbackUrl: '/' + }); - if (result?.ok) { - // Fetch the session to get the pubkey and privkey - const session = await getSession() - if (session?.pubkey && session?.privkey) { - localStorage.setItem('anonymousPubkey', session.pubkey) - localStorage.setItem('anonymousPrivkey', session.privkey) - router.push('/') - } else { - console.error("Pubkey or privkey not found in session") - } - // Redirect or update UI as needed - } else { - // Handle error - console.error("Anonymous login failed:", result?.error) + if (result?.ok) { + // Wait a moment for the session to be updated + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Fetch the session + const session = await getSession(); + + if (session?.user?.pubkey && session?.user?.privkey) { + localStorage.setItem('anonymousPubkey', session.user.pubkey); + localStorage.setItem('anonymousPrivkey', session.user.privkey); + router.push('/'); + } else { + console.error("Session data incomplete:", session); + } + } else { + console.error("Anonymous login failed:", result?.error); + } + } catch (error) { + console.error("Sign in error:", error); } - } + }; + + useEffect(() => { + // Redirect if already signed in + if (session?.user) { + router.push('/'); + } + }, [session, router]); return (
Sign in to ${new URL(url).host}
Sign in