import { createHash } from 'node:crypto' import NextAuth from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' import GitHubProvider from 'next-auth/providers/github' import TwitterProvider from 'next-auth/providers/twitter' import EmailProvider from 'next-auth/providers/email' import prisma from '@/api/models' import nodemailer from 'nodemailer' import { PrismaAdapter } from '@auth/prisma-adapter' import { getToken } from 'next-auth/jwt' import { NodeNextRequest } from 'next/dist/server/base-http/node' import { schnorr } from '@noble/curves/secp256k1' import { notifyReferral } from '@/lib/webPush' import { hashEmail } from '@/lib/crypto' /** * Stores userIds in user table * @returns {Partial} * */ function getEventCallbacks () { return { async linkAccount ({ user, profile, account }) { if (account.provider === 'github') { await prisma.user.update({ where: { id: user.id }, data: { githubId: profile.name } }) } else if (account.provider === 'twitter') { await prisma.user.update({ where: { id: user.id }, data: { twitterId: profile.name } }) } }, async signIn ({ user, profile, account, isNewUser }) { if (account.provider === 'github') { await prisma.user.update({ where: { id: user.id }, data: { githubId: profile.name } }) } else if (account.provider === 'twitter') { await prisma.user.update({ where: { id: user.id }, data: { twitterId: profile.name } }) } } } } async function getReferrerId (referrer) { try { if (referrer.startsWith('item-')) { return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } }))?.userId } else if (referrer.startsWith('profile-')) { return (await prisma.user.findUnique({ where: { name: referrer.slice(8) } }))?.id } else if (referrer.startsWith('territory-')) { return (await prisma.sub.findUnique({ where: { name: referrer.slice(10) } }))?.userId } else { return (await prisma.user.findUnique({ where: { name: referrer } }))?.id } } catch (error) { console.error('error getting referrer id', error) } } /** @returns {Partial} */ function getCallbacks (req) { return { /** * @param {object} token Decrypted JSON Web Token * @param {object} user User object (only available on sign in) * @param {object} account Provider account (only available on sign in) * @param {object} profile Provider profile (only available on sign in) * @param {boolean} isNewUser True if new user (only available on sign in) * @return {object} JSON Web Token that will be saved */ async jwt ({ token, user, account, profile, isNewUser }) { if (user) { // token won't have an id on it for new logins, we add it // note: token is what's kept in the jwt token.id = Number(user.id) // if referrer exists, set on user // isNewUser doesn't work for nostr/lightning auth because we create the user before nextauth can // this means users can update their referrer if they don't have one, which is fine if (req.cookies.sn_referrer && user?.id) { const referrerId = await getReferrerId(req.cookies.sn_referrer) if (referrerId && referrerId !== parseInt(user?.id)) { await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } }) notifyReferral(referrerId) } } } if (token?.id) { // HACK token.sub is used by nextjs v4 internally and is used like a userId // setting it here allows us to link multiple auth method to an account // ... in v3 this linking field was token.user.id token.sub = Number(token.id) } return token }, async session ({ session, token }) { // note: this function takes the current token (result of running jwt above) // and returns a new object session that's returned whenever get|use[Server]Session is called session.user.id = token.id return session } } } async function pubkeyAuth (credentials, req, pubkeyColumnName) { const { k1, pubkey } = credentials try { const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } }) await prisma.lnAuth.delete({ where: { k1 } }) if (lnauth.pubkey === pubkey) { let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } }) const token = await getToken({ req }) if (!user) { // if we are logged in, update rather than create if (token?.id) { user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } }) } else { user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } }) } } else if (token && token?.id !== user.id) { return null } return user } } catch (error) { console.log(error) } return null } async function nostrEventAuth (event) { // parse event const e = JSON.parse(event) // is the event id a hash of this event const id = createHash('sha256').update( JSON.stringify( [0, e.pubkey, e.created_at, e.kind, e.tags, e.content] ) ).digest('hex') if (id !== e.id) { throw new Error('invalid event id') } // is the signature valid if (!(await schnorr.verify(e.sig, e.id, e.pubkey))) { throw new Error('invalid signature') } // is the challenge present in the event if (!(e.tags[0].length === 2 && e.tags[0][0] === 'challenge')) { throw new Error('expected tags = [["challenge", ]]') } const pubkey = e.pubkey const k1 = e.tags[0][1] await prisma.lnAuth.update({ data: { pubkey }, where: { k1 } }) return { k1, pubkey } } /** @type {import('next-auth/providers').Provider[]} */ const providers = [ CredentialsProvider({ id: 'lightning', name: 'Lightning', credentials: { pubkey: { label: 'publickey', type: 'text' }, k1: { label: 'k1', type: 'text' } }, authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey') }), CredentialsProvider({ id: 'nostr', name: 'Nostr', credentials: { event: { label: 'event', type: 'text' } }, authorize: async ({ event }, req) => { const credentials = await nostrEventAuth(event) return await pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey') } }), GitHubProvider({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, authorization: { url: 'https://github.com/login/oauth/authorize', params: { scope: '' } }, profile (profile) { return { id: profile.id, name: profile.login } } }), TwitterProvider({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET, profile (profile) { return { id: profile.id, name: profile.screen_name } } }), EmailProvider({ server: process.env.LOGIN_EMAIL_SERVER, from: process.env.LOGIN_EMAIL_FROM, sendVerificationRequest }) ] /** @returns {import('next-auth').AuthOptions} */ export const getAuthOptions = req => ({ callbacks: getCallbacks(req), providers, adapter: { ...PrismaAdapter(prisma), createUser: data => { // replace email with email hash in new user payload if (data.email) { const { email } = data data.emailHash = hashEmail({ email }) delete data.email // data.email used to be used for name of new accounts. since it's missing, let's generate a new name data.name = data.emailHash.substring(0, 10) // sign them up for the newsletter // don't await it, let it run async enrollInNewsletter({ email }) } return prisma.user.create({ data }) }, getUserByEmail: async email => { const hashedEmail = hashEmail({ email }) let user = await prisma.user.findUnique({ where: { // lookup by email hash since we don't store plaintext emails any more emailHash: hashedEmail } }) if (!user) { user = await prisma.user.findUnique({ where: { // lookup by email as a fallback in case a user attempts to login by email during the migration // and their email hasn't been migrated yet email } }) } // HACK! This is required to satisfy next-auth's check here: // https://github.com/nextauthjs/next-auth/blob/5b647e1ac040250ad055e331ba97f8fa461b63cc/packages/next-auth/src/core/routes/callback.ts#L227 // since we are nulling `email`, but it expects it to be truthy there. // Since we have the email from the input request, we can copy it here and pretend like we store user emails, even though we don't. if (user) { user.email = email } return user } }, session: { strategy: 'jwt' }, pages: { signIn: '/login', verifyRequest: '/email', error: '/auth/error' }, events: getEventCallbacks() }) async function enrollInNewsletter ({ email }) { if (process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) { try { const response = await fetch(process.env.LIST_MONK_URL + '/api/subscribers', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64') }, body: JSON.stringify({ email, name: 'blank', lists: [2], status: 'enabled', preconfirm_subscriptions: true }) }) const jsonResponse = await response.json() console.log(jsonResponse) } catch (err) { console.log('error signing user up for newsletter') console.log(err) } } else { console.log('LIST MONK env vars not set, skipping newsletter enrollment') } } export default async (req, res) => { await NextAuth(req, res, getAuthOptions(req)) } async function sendVerificationRequest ({ identifier: email, url, provider }) { let user = await prisma.user.findUnique({ where: { // Look for the user by hashed email emailHash: hashEmail({ email }) } }) if (!user) { user = await prisma.user.findUnique({ where: { // or plaintext email, in case a user tries to login via email during the migration // before their particular record has been migrated email } }) } return new Promise((resolve, reject) => { const { server, from } = provider const site = new URL(url).host nodemailer.createTransport(server).sendMail( { to: email, from, subject: `login to ${site}`, text: text({ url, site, email }), html: user ? html({ url, site, email }) : newUserHtml({ url, site, email }) }, (error) => { if (error) { return reject(new Error('SEND_VERIFICATION_EMAIL_ERROR', error)) } return resolve() } ) }) } // Email HTML body const html = ({ url, site, email }) => { // Insert invisible space into domains and email address to prevent both the // email address and the domain from being turned into a hyperlink by email // clients like Outlook and Apple mail, as this is confusing because it seems // like they are supposed to click on their email address to sign in. const escapedEmail = `${email.replace(/\./g, '​.')}` const escapedSite = `${site.replace(/\./g, '​.')}` // Some simple styling options const backgroundColor = '#f5f5f5' const textColor = '#212529' const mainBackgroundColor = '#ffffff' const buttonBackgroundColor = '#FADA5E' const buttonTextColor = '#212529' // Uses tables for layout and inline CSS due to email client limitations return `
${escapedSite}
login as ${escapedEmail}
login
Or copy and paste this link: ${url}
If you did not request this email you can safely ignore it.
` } // Email text body –fallback for email clients that don't render HTML const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n` const newUserHtml = ({ url, site, email }) => { const escapedEmail = `${email.replace(/\./g, '​.')}` const replaceCb = (path) => { const urlObj = new URL(url) urlObj.searchParams.set('callbackUrl', path) return urlObj.href } const dailyUrl = replaceCb('/daily') const guideUrl = replaceCb('/guide') const faqUrl = replaceCb('/faq') const topUrl = replaceCb('/top/stackers/forever') const postUrl = replaceCb('/post') // Some simple styling options const backgroundColor = '#f5f5f5' const textColor = '#212529' const mainBackgroundColor = '#ffffff' const buttonBackgroundColor = '#FADA5E' return `
Welcome to Stacker News!
If you know how Stacker News works, click the login button below.
If you want to learn how Stacker News works, keep reading.
login as ${escapedEmail}
login
Or copy and paste this link: ${url}
Stacker News is like Reddit or Hacker News, but it pays you Bitcoin. Instead of giving posts or comments “upvotes,” Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.
In fact, some stackers have already stacked millions of sats just for posting and starting thoughtful conversations.
To start earning sats, click here to make your first post. You can share links, discussion questions, polls, or even bounties with other stackers. This guide offers some useful tips and best practices for sharing content on Stacker News.
If you’re not sure what to share, click here to introduce yourself to the community with a comment on the daily discussion thread.
If you still have questions, click here to learn more about Stacker News by reading our FAQ.
If anything isn’t clear, comment on the FAQ post and we’ll answer your question.
Zap,
Stacker News
P.S. Stacker News loves you!
If you did not request this email you can safely ignore it.
` }